mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-26 12:25:20 +01:00
[PM-9613] port forwarders to integrations (#10075)
* introduced forwarder integrations * simply contexts * report error and message when both are present in an RPC response
This commit is contained in:
parent
8f437dc773
commit
8c78959aaf
@ -1,9 +1,12 @@
|
|||||||
import { IntegrationContext } from "./integration-context";
|
import { IntegrationContext } from "./integration-context";
|
||||||
import { IntegrationMetadata } from "./integration-metadata";
|
import { IntegrationMetadata } from "./integration-metadata";
|
||||||
import { ApiSettings, TokenHeader } from "./rpc";
|
import { ApiSettings, IntegrationRequest, TokenHeader } from "./rpc";
|
||||||
|
|
||||||
/** Configures integration-wide settings */
|
/** Configures integration-wide settings */
|
||||||
export type IntegrationConfiguration = IntegrationMetadata & {
|
export type IntegrationConfiguration = IntegrationMetadata & {
|
||||||
/** Creates the authentication header for all integration remote procedure calls */
|
/** Creates the authentication header for all integration remote procedure calls */
|
||||||
authenticate: (settings: ApiSettings, context: IntegrationContext) => TokenHeader;
|
authenticate: (
|
||||||
|
request: IntegrationRequest,
|
||||||
|
context: IntegrationContext<ApiSettings>,
|
||||||
|
) => TokenHeader;
|
||||||
};
|
};
|
||||||
|
@ -25,7 +25,7 @@ describe("IntegrationContext", () => {
|
|||||||
|
|
||||||
describe("baseUrl", () => {
|
describe("baseUrl", () => {
|
||||||
it("outputs the base url from metadata", () => {
|
it("outputs the base url from metadata", () => {
|
||||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
|
||||||
|
|
||||||
const result = context.baseUrl();
|
const result = context.baseUrl();
|
||||||
|
|
||||||
@ -41,15 +41,15 @@ describe("IntegrationContext", () => {
|
|||||||
};
|
};
|
||||||
i18n.t.mockReturnValue("error");
|
i18n.t.mockReturnValue("error");
|
||||||
|
|
||||||
const context = new IntegrationContext(noBaseUrl, i18n);
|
const context = new IntegrationContext(noBaseUrl, null, i18n);
|
||||||
|
|
||||||
expect(() => context.baseUrl()).toThrow("error");
|
expect(() => context.baseUrl()).toThrow("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads from the settings", () => {
|
it("reads from the settings", () => {
|
||||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
const context = new IntegrationContext(EXAMPLE_META, { baseUrl: "httpbin.org" }, i18n);
|
||||||
|
|
||||||
const result = context.baseUrl({ baseUrl: "httpbin.org" });
|
const result = context.baseUrl();
|
||||||
|
|
||||||
expect(result).toBe("httpbin.org");
|
expect(result).toBe("httpbin.org");
|
||||||
});
|
});
|
||||||
@ -62,9 +62,9 @@ describe("IntegrationContext", () => {
|
|||||||
baseUrl: "example.com",
|
baseUrl: "example.com",
|
||||||
selfHost: "never",
|
selfHost: "never",
|
||||||
};
|
};
|
||||||
const context = new IntegrationContext(selfHostNever, i18n);
|
const context = new IntegrationContext(selfHostNever, { baseUrl: "httpbin.org" }, i18n);
|
||||||
|
|
||||||
const result = context.baseUrl({ baseUrl: "httpbin.org" });
|
const result = context.baseUrl();
|
||||||
|
|
||||||
expect(result).toBe("example.com");
|
expect(result).toBe("example.com");
|
||||||
});
|
});
|
||||||
@ -77,11 +77,22 @@ describe("IntegrationContext", () => {
|
|||||||
baseUrl: "example.com",
|
baseUrl: "example.com",
|
||||||
selfHost: "always",
|
selfHost: "always",
|
||||||
};
|
};
|
||||||
const context = new IntegrationContext(selfHostAlways, i18n);
|
const context = new IntegrationContext(selfHostAlways, { baseUrl: "http.bin" }, i18n);
|
||||||
|
|
||||||
// expect success
|
// expect success
|
||||||
const result = context.baseUrl({ baseUrl: "http.bin" });
|
const result = context.baseUrl();
|
||||||
expect(result).toBe("http.bin");
|
expect(result).toBe("http.bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the settings are empty and selfhost is 'always'", () => {
|
||||||
|
const selfHostAlways: IntegrationMetadata = {
|
||||||
|
id: "simplelogin" as IntegrationId, // arbitrary
|
||||||
|
name: "Example",
|
||||||
|
extends: ["forwarder"], // arbitrary
|
||||||
|
baseUrl: "example.com",
|
||||||
|
selfHost: "always",
|
||||||
|
};
|
||||||
|
const context = new IntegrationContext(selfHostAlways, {}, i18n);
|
||||||
|
|
||||||
// expect error
|
// expect error
|
||||||
i18n.t.mockReturnValue("error");
|
i18n.t.mockReturnValue("error");
|
||||||
@ -97,7 +108,7 @@ describe("IntegrationContext", () => {
|
|||||||
selfHost: "maybe",
|
selfHost: "maybe",
|
||||||
};
|
};
|
||||||
|
|
||||||
const context = new IntegrationContext(selfHostMaybe, i18n);
|
const context = new IntegrationContext(selfHostMaybe, null, i18n);
|
||||||
|
|
||||||
const result = context.baseUrl();
|
const result = context.baseUrl();
|
||||||
|
|
||||||
@ -113,9 +124,9 @@ describe("IntegrationContext", () => {
|
|||||||
selfHost: "maybe",
|
selfHost: "maybe",
|
||||||
};
|
};
|
||||||
|
|
||||||
const context = new IntegrationContext(selfHostMaybe, i18n);
|
const context = new IntegrationContext(selfHostMaybe, { baseUrl: "httpbin.org" }, i18n);
|
||||||
|
|
||||||
const result = context.baseUrl({ baseUrl: "httpbin.org" });
|
const result = context.baseUrl();
|
||||||
|
|
||||||
expect(result).toBe("httpbin.org");
|
expect(result).toBe("httpbin.org");
|
||||||
});
|
});
|
||||||
@ -123,39 +134,47 @@ describe("IntegrationContext", () => {
|
|||||||
|
|
||||||
describe("authenticationToken", () => {
|
describe("authenticationToken", () => {
|
||||||
it("reads from the settings", () => {
|
it("reads from the settings", () => {
|
||||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
const context = new IntegrationContext(EXAMPLE_META, { token: "example" }, i18n);
|
||||||
|
|
||||||
const result = context.authenticationToken({ token: "example" });
|
const result = context.authenticationToken();
|
||||||
|
|
||||||
expect(result).toBe("example");
|
expect(result).toBe("example");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("base64 encodes the read value", () => {
|
it("suffix is appended to the token", () => {
|
||||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
const context = new IntegrationContext(EXAMPLE_META, { token: "example" }, i18n);
|
||||||
|
|
||||||
const result = context.authenticationToken({ token: "example" }, { base64: true });
|
const result = context.authenticationToken({ suffix: " with suffix" });
|
||||||
|
|
||||||
|
expect(result).toBe("example with suffix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("base64 encodes the read value", () => {
|
||||||
|
const context = new IntegrationContext(EXAMPLE_META, { token: "example" }, i18n);
|
||||||
|
|
||||||
|
const result = context.authenticationToken({ base64: true });
|
||||||
|
|
||||||
expect(result).toBe("ZXhhbXBsZQ==");
|
expect(result).toBe("ZXhhbXBsZQ==");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error when the value is missing", () => {
|
it("throws an error when the value is missing", () => {
|
||||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
const context = new IntegrationContext(EXAMPLE_META, {}, i18n);
|
||||||
i18n.t.mockReturnValue("error");
|
i18n.t.mockReturnValue("error");
|
||||||
|
|
||||||
expect(() => context.authenticationToken({})).toThrow("error");
|
expect(() => context.authenticationToken()).toThrow("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error when the value is empty", () => {
|
it.each([[undefined], [null], [""]])("throws an error when the value is %p", (token) => {
|
||||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
const context = new IntegrationContext(EXAMPLE_META, { token }, i18n);
|
||||||
i18n.t.mockReturnValue("error");
|
i18n.t.mockReturnValue("error");
|
||||||
|
|
||||||
expect(() => context.authenticationToken({ token: "" })).toThrow("error");
|
expect(() => context.authenticationToken()).toThrow("error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("website", () => {
|
describe("website", () => {
|
||||||
it("returns the website", () => {
|
it("returns the website", () => {
|
||||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
|
||||||
|
|
||||||
const result = context.website({ website: "www.example.com" });
|
const result = context.website({ website: "www.example.com" });
|
||||||
|
|
||||||
@ -163,7 +182,7 @@ describe("IntegrationContext", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty string when the website is not specified", () => {
|
it("returns an empty string when the website is not specified", () => {
|
||||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
|
||||||
|
|
||||||
const result = context.website({ website: undefined });
|
const result = context.website({ website: undefined });
|
||||||
|
|
||||||
@ -173,7 +192,7 @@ describe("IntegrationContext", () => {
|
|||||||
|
|
||||||
describe("generatedBy", () => {
|
describe("generatedBy", () => {
|
||||||
it("creates generated by text", () => {
|
it("creates generated by text", () => {
|
||||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
|
||||||
i18n.t.mockReturnValue("result");
|
i18n.t.mockReturnValue("result");
|
||||||
|
|
||||||
const result = context.generatedBy({ website: null });
|
const result = context.generatedBy({ website: null });
|
||||||
@ -183,7 +202,7 @@ describe("IntegrationContext", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates generated by text including the website", () => {
|
it("creates generated by text including the website", () => {
|
||||||
const context = new IntegrationContext(EXAMPLE_META, i18n);
|
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
|
||||||
i18n.t.mockReturnValue("result");
|
i18n.t.mockReturnValue("result");
|
||||||
|
|
||||||
const result = context.generatedBy({ website: "www.example.com" });
|
const result = context.generatedBy({ website: "www.example.com" });
|
||||||
|
@ -2,30 +2,33 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
|||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
|
|
||||||
import { IntegrationMetadata } from "./integration-metadata";
|
import { IntegrationMetadata } from "./integration-metadata";
|
||||||
import { ApiSettings, SelfHostedApiSettings, IntegrationRequest } from "./rpc";
|
import { ApiSettings, IntegrationRequest } from "./rpc";
|
||||||
|
|
||||||
/** Utilities for processing integration settings */
|
/** Utilities for processing integration settings */
|
||||||
export class IntegrationContext {
|
export class IntegrationContext<Settings extends object> {
|
||||||
/** Instantiates an integration context
|
/** Instantiates an integration context
|
||||||
* @param metadata - defines integration capabilities
|
* @param metadata - defines integration capabilities
|
||||||
* @param i18n - localizes error messages
|
* @param i18n - localizes error messages
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
readonly metadata: IntegrationMetadata,
|
readonly metadata: IntegrationMetadata,
|
||||||
|
protected settings: Settings,
|
||||||
protected i18n: I18nService,
|
protected i18n: I18nService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Lookup the integration's baseUrl
|
/** Lookup the integration's baseUrl
|
||||||
* @param settings settings that override the baseUrl.
|
|
||||||
* @returns the baseUrl for the API's integration point.
|
* @returns the baseUrl for the API's integration point.
|
||||||
* - By default this is defined by the metadata
|
* - By default this is defined by the metadata
|
||||||
* - When a service allows self-hosting, this can be supplied by `settings`.
|
* - When a service allows self-hosting, this can be supplied by `settings`.
|
||||||
* @throws a localized error message when a base URL is neither defined by the metadata or
|
* @throws a localized error message when a base URL is neither defined by the metadata or
|
||||||
* supplied by an argument.
|
* supplied by an argument.
|
||||||
*/
|
*/
|
||||||
baseUrl(settings?: SelfHostedApiSettings) {
|
baseUrl(): string {
|
||||||
// normalize baseUrl
|
// normalize baseUrl
|
||||||
const setting = settings && "baseUrl" in settings ? settings.baseUrl : "";
|
const setting =
|
||||||
|
(this.settings && "baseUrl" in this.settings
|
||||||
|
? (this.settings.baseUrl as string)
|
||||||
|
: undefined) ?? "";
|
||||||
let result = "";
|
let result = "";
|
||||||
|
|
||||||
// look up definition
|
// look up definition
|
||||||
@ -47,18 +50,24 @@ export class IntegrationContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** look up a service API's authentication token
|
/** look up a service API's authentication token
|
||||||
* @param settings store the API token
|
|
||||||
* @param options.base64 when `true`, base64 encodes the result. Defaults to `false`.
|
* @param options.base64 when `true`, base64 encodes the result. Defaults to `false`.
|
||||||
|
* @param options.suffix a string to append to the token. Defaults to empty.
|
||||||
* @returns the user's authentication token
|
* @returns the user's authentication token
|
||||||
* @throws a localized error message when the token is invalid.
|
* @throws a localized error message when the token is invalid.
|
||||||
|
* @remarks the string is thrown for backwards compatibility
|
||||||
*/
|
*/
|
||||||
authenticationToken(settings: ApiSettings, options: { base64?: boolean } = null) {
|
authenticationToken(
|
||||||
if (!settings.token || settings.token === "") {
|
options: { base64?: boolean; suffix?: string } = null,
|
||||||
|
): Settings extends ApiSettings ? string : never {
|
||||||
|
// normalize `token` then assert it has a value
|
||||||
|
let token = "token" in this.settings ? ((this.settings.token as string) ?? "") : "";
|
||||||
|
if (token === "") {
|
||||||
const error = this.i18n.t("forwaderInvalidToken", this.metadata.name);
|
const error = this.i18n.t("forwaderInvalidToken", this.metadata.name);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = settings.token;
|
// if a suffix exists, it needs to be included before encoding
|
||||||
|
token += options?.suffix ?? "";
|
||||||
if (options?.base64) {
|
if (options?.base64) {
|
||||||
token = Utils.fromUtf8ToB64(token);
|
token = Utils.fromUtf8ToB64(token);
|
||||||
}
|
}
|
||||||
|
@ -51,17 +51,41 @@ describe("RestClient", () => {
|
|||||||
expect(api.nativeFetch).toHaveBeenCalledWith(expectedRpc.fetchRequest);
|
expect(api.nativeFetch).toHaveBeenCalledWith(expectedRpc.fetchRequest);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([[401], [403]])(
|
it.each([[401] /*,[403]*/])(
|
||||||
"throws an invalid token error when HTTP status is %i",
|
"throws an invalid token error when HTTP status is %i",
|
||||||
async (status) => {
|
async (status) => {
|
||||||
const client = new RestClient(api, i18n);
|
const client = new RestClient(api, i18n);
|
||||||
const request: IntegrationRequest = { website: null };
|
const request: IntegrationRequest = { website: null };
|
||||||
const response = mock<Response>({ status });
|
const response = mock<Response>({ status, statusText: null });
|
||||||
api.nativeFetch.mockResolvedValue(response);
|
api.nativeFetch.mockResolvedValue(response);
|
||||||
|
|
||||||
const result = client.fetchJson(rpc, request);
|
const result = client.fetchJson(rpc, request);
|
||||||
|
|
||||||
await expect(result).rejects.toEqual("forwarderInvalidToken");
|
await expect(result).rejects.toEqual("forwaderInvalidToken");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[401, null, null],
|
||||||
|
[401, undefined, undefined],
|
||||||
|
[401, undefined, null],
|
||||||
|
[403, null, null],
|
||||||
|
[403, undefined, undefined],
|
||||||
|
[403, undefined, null],
|
||||||
|
])(
|
||||||
|
"throws an invalid token error when HTTP status is %i, message is %p, and error is %p",
|
||||||
|
async (status) => {
|
||||||
|
const client = new RestClient(api, i18n);
|
||||||
|
const request: IntegrationRequest = { website: null };
|
||||||
|
const response = mock<Response>({
|
||||||
|
status,
|
||||||
|
text: () => Promise.resolve(`{ "message": null, "error": null }`),
|
||||||
|
});
|
||||||
|
api.nativeFetch.mockResolvedValue(response);
|
||||||
|
|
||||||
|
const result = client.fetchJson(rpc, request);
|
||||||
|
|
||||||
|
await expect(result).rejects.toEqual("forwaderInvalidToken");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -83,16 +107,73 @@ describe("RestClient", () => {
|
|||||||
|
|
||||||
const result = client.fetchJson(rpc, request);
|
const result = client.fetchJson(rpc, request);
|
||||||
|
|
||||||
await expect(result).rejects.toEqual("forwarderInvalidTokenWithMessage");
|
await expect(result).rejects.toEqual("forwaderInvalidTokenWithMessage");
|
||||||
expect(i18n.t).toHaveBeenCalledWith(
|
expect(i18n.t).toHaveBeenCalledWith(
|
||||||
"forwarderInvalidTokenWithMessage",
|
"forwaderInvalidTokenWithMessage",
|
||||||
"mock",
|
"mock",
|
||||||
"expected message",
|
"expected message",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each([[500], [501]])(
|
it.each([[401], [403]])(
|
||||||
|
"throws an invalid token detailed error when HTTP status is %i and the payload has a %s",
|
||||||
|
async (status) => {
|
||||||
|
const client = new RestClient(api, i18n);
|
||||||
|
const request: IntegrationRequest = { website: null };
|
||||||
|
const response = mock<Response>({
|
||||||
|
status,
|
||||||
|
text: () =>
|
||||||
|
Promise.resolve(`{ "error": "that happened", "message": "expected message" }`),
|
||||||
|
});
|
||||||
|
api.nativeFetch.mockResolvedValue(response);
|
||||||
|
|
||||||
|
const result = client.fetchJson(rpc, request);
|
||||||
|
|
||||||
|
await expect(result).rejects.toEqual("forwaderInvalidTokenWithMessage");
|
||||||
|
expect(i18n.t).toHaveBeenCalledWith(
|
||||||
|
"forwaderInvalidTokenWithMessage",
|
||||||
|
"mock",
|
||||||
|
"that happened: expected message",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([[429], [500], [501]])(
|
||||||
|
"throws a forwarder error when HTTP status is %i",
|
||||||
|
async (status) => {
|
||||||
|
const client = new RestClient(api, i18n);
|
||||||
|
const request: IntegrationRequest = { website: null };
|
||||||
|
const response = mock<Response>({ status, statusText: null });
|
||||||
|
api.nativeFetch.mockResolvedValue(response);
|
||||||
|
|
||||||
|
const result = client.fetchJson(rpc, request);
|
||||||
|
|
||||||
|
await expect(result).rejects.toEqual("forwarderUnknownError");
|
||||||
|
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([[429], [500], [501]])(
|
||||||
|
"throws a forwarder error when HTTP status is %i and the body is empty",
|
||||||
|
async (status) => {
|
||||||
|
const client = new RestClient(api, i18n);
|
||||||
|
const request: IntegrationRequest = { website: null };
|
||||||
|
const response = mock<Response>({
|
||||||
|
status,
|
||||||
|
statusText: null,
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
});
|
||||||
|
api.nativeFetch.mockResolvedValue(response);
|
||||||
|
|
||||||
|
const result = client.fetchJson(rpc, request);
|
||||||
|
|
||||||
|
await expect(result).rejects.toEqual("forwarderUnknownError");
|
||||||
|
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([[429], [500], [501]])(
|
||||||
"throws a forwarder error with the status text when HTTP status is %i",
|
"throws a forwarder error with the status text when HTTP status is %i",
|
||||||
async (status) => {
|
async (status) => {
|
||||||
const client = new RestClient(api, i18n);
|
const client = new RestClient(api, i18n);
|
||||||
@ -108,8 +189,10 @@ describe("RestClient", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
[429, "message"],
|
||||||
[500, "message"],
|
[500, "message"],
|
||||||
[500, "message"],
|
[500, "message"],
|
||||||
|
[429, "error"],
|
||||||
[501, "error"],
|
[501, "error"],
|
||||||
[501, "error"],
|
[501, "error"],
|
||||||
])(
|
])(
|
||||||
@ -130,6 +213,61 @@ describe("RestClient", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each([[429], [500], [500]])(
|
||||||
|
"throws a detailed forwarder error when HTTP status is %i and the payload is a string",
|
||||||
|
async (status) => {
|
||||||
|
const client = new RestClient(api, i18n);
|
||||||
|
const request: IntegrationRequest = { website: null };
|
||||||
|
const response = mock<Response>({
|
||||||
|
status,
|
||||||
|
text: () => Promise.resolve('"expected message"'),
|
||||||
|
});
|
||||||
|
api.nativeFetch.mockResolvedValue(response);
|
||||||
|
|
||||||
|
const result = client.fetchJson(rpc, request);
|
||||||
|
|
||||||
|
await expect(result).rejects.toEqual("forwarderError");
|
||||||
|
expect(i18n.t).toHaveBeenCalledWith("forwarderError", "mock", "expected message");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([[429], [500], [500]])(
|
||||||
|
"throws an unknown forwarder error when HTTP status is %i and the payload could contain an html tag",
|
||||||
|
async (status) => {
|
||||||
|
const client = new RestClient(api, i18n);
|
||||||
|
const request: IntegrationRequest = { website: null };
|
||||||
|
const response = mock<Response>({
|
||||||
|
status,
|
||||||
|
statusText: null,
|
||||||
|
text: () => Promise.resolve("<head>"),
|
||||||
|
});
|
||||||
|
api.nativeFetch.mockResolvedValue(response);
|
||||||
|
|
||||||
|
const result = client.fetchJson(rpc, request);
|
||||||
|
|
||||||
|
await expect(result).rejects.toEqual("forwarderUnknownError");
|
||||||
|
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([[429], [500], [500]])(
|
||||||
|
"throws a unknown forwarder error when HTTP status is %i and the payload is malformed",
|
||||||
|
async (status) => {
|
||||||
|
const client = new RestClient(api, i18n);
|
||||||
|
const request: IntegrationRequest = { website: null };
|
||||||
|
const response = mock<Response>({
|
||||||
|
status,
|
||||||
|
text: () => Promise.resolve(`{ foo: "not json" }`),
|
||||||
|
});
|
||||||
|
api.nativeFetch.mockResolvedValue(response);
|
||||||
|
|
||||||
|
const result = client.fetchJson(rpc, request);
|
||||||
|
|
||||||
|
await expect(result).rejects.toEqual("forwarderUnknownError");
|
||||||
|
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("outputs an error if there's no json payload", async () => {
|
it("outputs an error if there's no json payload", async () => {
|
||||||
const client = new RestClient(api, i18n);
|
const client = new RestClient(api, i18n);
|
||||||
rpc.hasJsonPayload.mockReturnValue(false);
|
rpc.hasJsonPayload.mockReturnValue(false);
|
||||||
|
@ -10,59 +10,96 @@ export class RestClient {
|
|||||||
private api: ApiService,
|
private api: ApiService,
|
||||||
private i18n: I18nService,
|
private i18n: I18nService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** uses the fetch API to request a JSON payload. */
|
/** uses the fetch API to request a JSON payload. */
|
||||||
async fetchJson<Parameters extends IntegrationRequest, Response>(
|
// FIXME: once legacy password generator is removed, replace forwarder-specific error
|
||||||
rpc: JsonRpc<Parameters, Response>,
|
// messages with RPC-generalized ones.
|
||||||
|
async fetchJson<Parameters extends IntegrationRequest, Result>(
|
||||||
|
rpc: JsonRpc<Parameters, Result>,
|
||||||
params: Parameters,
|
params: Parameters,
|
||||||
): Promise<Response> {
|
): Promise<Result> {
|
||||||
|
// run the request
|
||||||
const request = rpc.toRequest(params);
|
const request = rpc.toRequest(params);
|
||||||
const response = await this.api.nativeFetch(request);
|
const response = await this.api.nativeFetch(request);
|
||||||
|
|
||||||
// FIXME: once legacy password generator is removed, replace forwarder-specific error
|
let result: Result = undefined;
|
||||||
// messages with RPC-generalized ones.
|
let errorKey: string = undefined;
|
||||||
let error: string = undefined;
|
let errorMessage: string = undefined;
|
||||||
let cause: string = undefined;
|
|
||||||
|
|
||||||
|
const commonError = await this.detectCommonErrors(response);
|
||||||
|
if (commonError) {
|
||||||
|
[errorKey, errorMessage] = commonError;
|
||||||
|
} else if (rpc.hasJsonPayload(response)) {
|
||||||
|
[result, errorMessage] = rpc.processJson(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle failures
|
||||||
|
errorKey ??= errorMessage ? "forwarderError" : "forwarderUnknownError";
|
||||||
|
const error = this.i18n.t(errorKey, rpc.requestor.name, errorMessage);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectCommonErrors(response: Response): Promise<[string, string] | undefined> {
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
cause = await this.tryGetErrorMessage(response);
|
const message = await this.tryGetErrorMessage(response);
|
||||||
error = cause ? "forwarderInvalidTokenWithMessage" : "forwarderInvalidToken";
|
const key = message ? "forwaderInvalidTokenWithMessage" : "forwaderInvalidToken";
|
||||||
} else if (response.status >= 500) {
|
return [key, message];
|
||||||
cause = await this.tryGetErrorMessage(response);
|
} else if (response.status === 429 || response.status >= 500) {
|
||||||
cause = cause ?? response.statusText;
|
const message = await this.tryGetErrorMessage(response);
|
||||||
error = "forwarderError";
|
const key = message ? "forwarderError" : "forwarderUnknownError";
|
||||||
|
return [key, message];
|
||||||
}
|
}
|
||||||
|
|
||||||
let ok: Response = undefined;
|
|
||||||
if (!error && rpc.hasJsonPayload(response)) {
|
|
||||||
[ok, cause] = rpc.processJson(await response.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
// success
|
|
||||||
if (ok) {
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
// failure
|
|
||||||
if (!error) {
|
|
||||||
error = cause ? "forwarderError" : "forwarderUnknownError";
|
|
||||||
}
|
|
||||||
throw this.i18n.t(error, rpc.requestor.name, cause);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryGetErrorMessage(response: Response) {
|
private async tryGetErrorMessage(response: Response) {
|
||||||
const body = (await response.text()) ?? "";
|
const body = (await response.text()) ?? "";
|
||||||
|
|
||||||
if (!body.startsWith("{")) {
|
// nullish continues processing; false returns undefined
|
||||||
|
const error =
|
||||||
|
this.tryFindErrorAsJson(body) ?? this.tryFindErrorAsText(body) ?? response.statusText;
|
||||||
|
|
||||||
|
return error || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryFindErrorAsJson(body: string) {
|
||||||
|
// tryParse JSON object or string
|
||||||
|
const parsable = body.startsWith("{") || body.startsWith(`'`) || body.startsWith(`"`);
|
||||||
|
if (!parsable) {
|
||||||
|
// fail-and-continue because it's not JSON
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let parsed = undefined;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
// fail-and-exit in case `body` is malformed JSON
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// could be a string
|
||||||
|
if (parsed && typeof parsed === "string") {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// could be { error?: T, message?: U }
|
||||||
|
const error = parsed.error?.toString() ?? null;
|
||||||
|
const message = parsed.message?.toString() ?? null;
|
||||||
|
|
||||||
|
// `false` signals no message found
|
||||||
|
const result = error && message ? `${error}: ${message}` : (error ?? message ?? false);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryFindErrorAsText(body: string) {
|
||||||
|
if (!body.length || body.includes("<")) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = JSON.parse(body);
|
return body;
|
||||||
if ("error" in json) {
|
|
||||||
return json.error;
|
|
||||||
} else if ("message" in json) {
|
|
||||||
return json.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
37
libs/common/src/tools/state/classifier.ts
Normal file
37
libs/common/src/tools/state/classifier.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
/** Classifies an object's JSON-serializable data by property into
|
||||||
|
* 3 categories:
|
||||||
|
* * Disclosed data MAY be stored in plaintext.
|
||||||
|
* * Excluded data MUST NOT be saved.
|
||||||
|
* * The remaining data is secret and MUST be stored using encryption.
|
||||||
|
*
|
||||||
|
* This type should not be used to classify functions.
|
||||||
|
* Data that cannot be serialized by JSON.stringify() should
|
||||||
|
* be excluded.
|
||||||
|
*/
|
||||||
|
export interface Classifier<Plaintext, Disclosed, Secret> {
|
||||||
|
/** Partitions `secret` into its disclosed properties and secret properties.
|
||||||
|
* @param value The object to partition
|
||||||
|
* @returns an object that classifies secrets.
|
||||||
|
* The `disclosed` member is new and contains disclosed properties.
|
||||||
|
* The `secret` member is a copy of the secret parameter, including its
|
||||||
|
* prototype, with all disclosed and excluded properties deleted.
|
||||||
|
*/
|
||||||
|
classify(value: Plaintext): { disclosed: Jsonify<Disclosed>; secret: Jsonify<Secret> };
|
||||||
|
|
||||||
|
/** Merges the properties of `secret` and `disclosed`. When `secret` and
|
||||||
|
* `disclosed` contain the same property, the `secret` property overrides
|
||||||
|
* the `disclosed` property.
|
||||||
|
* @param disclosed an object whose disclosed properties are merged into
|
||||||
|
* the output. Unknown properties are ignored.
|
||||||
|
* @param secret an objects whose properties are merged into the output.
|
||||||
|
* Excluded properties are ignored. Unknown properties are retained.
|
||||||
|
* @returns a new object containing the merged data.
|
||||||
|
*
|
||||||
|
* @remarks Declassified data is always jsonified--the purpose of classifying it is
|
||||||
|
* to Jsonify it,
|
||||||
|
* which causes type conversions.
|
||||||
|
*/
|
||||||
|
declassify(disclosed: Jsonify<Disclosed>, secret: Jsonify<Secret>): Jsonify<Plaintext>;
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
import { Jsonify } from "type-fest";
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
import { Classifier } from "./classifier";
|
||||||
|
|
||||||
/** Classifies an object's JSON-serializable data by property into
|
/** Classifies an object's JSON-serializable data by property into
|
||||||
* 3 categories:
|
* 3 categories:
|
||||||
* * Disclosed data MAY be stored in plaintext.
|
* * Disclosed data MAY be stored in plaintext.
|
||||||
@ -10,7 +12,9 @@ import { Jsonify } from "type-fest";
|
|||||||
* Data that cannot be serialized by JSON.stringify() should
|
* Data that cannot be serialized by JSON.stringify() should
|
||||||
* be excluded.
|
* be excluded.
|
||||||
*/
|
*/
|
||||||
export class SecretClassifier<Plaintext extends object, Disclosed, Secret> {
|
export class SecretClassifier<Plaintext extends object, Disclosed, Secret>
|
||||||
|
implements Classifier<Plaintext, Disclosed, Secret>
|
||||||
|
{
|
||||||
private constructor(
|
private constructor(
|
||||||
disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[],
|
disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[],
|
||||||
excluded: readonly (keyof Plaintext)[],
|
excluded: readonly (keyof Plaintext)[],
|
||||||
|
@ -1,15 +1,19 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
import { GENERATOR_DISK, UserKeyDefinitionOptions } from "../../platform/state";
|
import { GENERATOR_DISK, UserKeyDefinitionOptions } from "../../platform/state";
|
||||||
|
|
||||||
import { SecretClassifier } from "./secret-classifier";
|
import { Classifier } from "./classifier";
|
||||||
import { SecretKeyDefinition } from "./secret-key-definition";
|
import { SecretKeyDefinition } from "./secret-key-definition";
|
||||||
|
|
||||||
describe("SecretKeyDefinition", () => {
|
describe("SecretKeyDefinition", () => {
|
||||||
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
|
type TestData = { foo: boolean };
|
||||||
|
const classifier = mock<Classifier<any, Record<string, never>, TestData>>();
|
||||||
const options: UserKeyDefinitionOptions<any> = { deserializer: (v: any) => v, clearOn: [] };
|
const options: UserKeyDefinitionOptions<any> = { deserializer: (v: any) => v, clearOn: [] };
|
||||||
|
|
||||||
it("toEncryptedStateKey returns a key", () => {
|
it("toEncryptedStateKey returns a key", () => {
|
||||||
const expectedOptions: UserKeyDefinitionOptions<any> = {
|
const expectedOptions: UserKeyDefinitionOptions<TestData> = {
|
||||||
deserializer: (v: any) => v,
|
deserializer: (v: Jsonify<TestData>) => v,
|
||||||
cleanupDelayMs: 100,
|
cleanupDelayMs: 100,
|
||||||
clearOn: ["logout", "lock"],
|
clearOn: ["logout", "lock"],
|
||||||
};
|
};
|
||||||
|
@ -2,7 +2,7 @@ import { UserKeyDefinitionOptions, UserKeyDefinition } from "../../platform/stat
|
|||||||
// eslint-disable-next-line -- `StateDefinition` used as an argument
|
// eslint-disable-next-line -- `StateDefinition` used as an argument
|
||||||
import { StateDefinition } from "../../platform/state/state-definition";
|
import { StateDefinition } from "../../platform/state/state-definition";
|
||||||
import { ClassifiedFormat } from "./classified-format";
|
import { ClassifiedFormat } from "./classified-format";
|
||||||
import { SecretClassifier } from "./secret-classifier";
|
import { Classifier } from "./classifier";
|
||||||
|
|
||||||
/** Encryption and storage settings for data stored by a `SecretState`.
|
/** Encryption and storage settings for data stored by a `SecretState`.
|
||||||
*/
|
*/
|
||||||
@ -10,7 +10,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
|
|||||||
private constructor(
|
private constructor(
|
||||||
readonly stateDefinition: StateDefinition,
|
readonly stateDefinition: StateDefinition,
|
||||||
readonly key: string,
|
readonly key: string,
|
||||||
readonly classifier: SecretClassifier<Inner, Disclosed, Secret>,
|
readonly classifier: Classifier<Inner, Disclosed, Secret>,
|
||||||
readonly options: UserKeyDefinitionOptions<Inner>,
|
readonly options: UserKeyDefinitionOptions<Inner>,
|
||||||
// type erasure is necessary here because typescript doesn't support
|
// type erasure is necessary here because typescript doesn't support
|
||||||
// higher kinded types that generalize over collections. The invariants
|
// higher kinded types that generalize over collections. The invariants
|
||||||
@ -46,7 +46,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
|
|||||||
static value<Value extends object, Disclosed, Secret>(
|
static value<Value extends object, Disclosed, Secret>(
|
||||||
stateDefinition: StateDefinition,
|
stateDefinition: StateDefinition,
|
||||||
key: string,
|
key: string,
|
||||||
classifier: SecretClassifier<Value, Disclosed, Secret>,
|
classifier: Classifier<Value, Disclosed, Secret>,
|
||||||
options: UserKeyDefinitionOptions<Value>,
|
options: UserKeyDefinitionOptions<Value>,
|
||||||
) {
|
) {
|
||||||
return new SecretKeyDefinition<Value, void, Value, Disclosed, Secret>(
|
return new SecretKeyDefinition<Value, void, Value, Disclosed, Secret>(
|
||||||
@ -70,7 +70,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
|
|||||||
static array<Item extends object, Disclosed, Secret>(
|
static array<Item extends object, Disclosed, Secret>(
|
||||||
stateDefinition: StateDefinition,
|
stateDefinition: StateDefinition,
|
||||||
key: string,
|
key: string,
|
||||||
classifier: SecretClassifier<Item, Disclosed, Secret>,
|
classifier: Classifier<Item, Disclosed, Secret>,
|
||||||
options: UserKeyDefinitionOptions<Item>,
|
options: UserKeyDefinitionOptions<Item>,
|
||||||
) {
|
) {
|
||||||
return new SecretKeyDefinition<Item[], number, Item, Disclosed, Secret>(
|
return new SecretKeyDefinition<Item[], number, Item, Disclosed, Secret>(
|
||||||
@ -94,7 +94,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
|
|||||||
static record<Item extends object, Disclosed, Secret, Id extends string | number>(
|
static record<Item extends object, Disclosed, Secret, Id extends string | number>(
|
||||||
stateDefinition: StateDefinition,
|
stateDefinition: StateDefinition,
|
||||||
key: string,
|
key: string,
|
||||||
classifier: SecretClassifier<Item, Disclosed, Secret>,
|
classifier: Classifier<Item, Disclosed, Secret>,
|
||||||
options: UserKeyDefinitionOptions<Item>,
|
options: UserKeyDefinitionOptions<Item>,
|
||||||
) {
|
) {
|
||||||
return new SecretKeyDefinition<Record<Id, Item>, Id, Item, Disclosed, Secret>(
|
return new SecretKeyDefinition<Record<Id, Item>, Id, Item, Disclosed, Secret>(
|
||||||
|
@ -14,5 +14,6 @@ export * from "./default-simple-login-options";
|
|||||||
export * from "./disabled-passphrase-generator-policy";
|
export * from "./disabled-passphrase-generator-policy";
|
||||||
export * from "./disabled-password-generator-policy";
|
export * from "./disabled-password-generator-policy";
|
||||||
export * from "./forwarders";
|
export * from "./forwarders";
|
||||||
|
export * from "./integrations";
|
||||||
export * from "./policies";
|
export * from "./policies";
|
||||||
export * from "./username-digits";
|
export * from "./username-digits";
|
||||||
|
15
libs/tools/generator/core/src/data/integrations.ts
Normal file
15
libs/tools/generator/core/src/data/integrations.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { AddyIo } from "../integration/addy-io";
|
||||||
|
import { DuckDuckGo } from "../integration/duck-duck-go";
|
||||||
|
import { Fastmail } from "../integration/fastmail";
|
||||||
|
import { FirefoxRelay } from "../integration/firefox-relay";
|
||||||
|
import { ForwardEmail } from "../integration/forward-email";
|
||||||
|
import { SimpleLogin } from "../integration/simple-login";
|
||||||
|
|
||||||
|
export const Integrations = Object.freeze({
|
||||||
|
AddyIo,
|
||||||
|
DuckDuckGo,
|
||||||
|
Fastmail,
|
||||||
|
FirefoxRelay,
|
||||||
|
ForwardEmail,
|
||||||
|
SimpleLogin,
|
||||||
|
} as const);
|
@ -0,0 +1,51 @@
|
|||||||
|
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
import { IntegrationConfiguration } from "@bitwarden/common/tools/integration/integration-configuration";
|
||||||
|
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc/integration-request";
|
||||||
|
import { RpcConfiguration } from "@bitwarden/common/tools/integration/rpc/rpc-definition";
|
||||||
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
|
||||||
|
import { ForwarderContext } from "./forwarder-context";
|
||||||
|
|
||||||
|
/** Mixin for transmitting `getAccountId` result. */
|
||||||
|
export type AccountRequest = {
|
||||||
|
accountId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** definition of the create forwarding request api call for an integration */
|
||||||
|
export type CreateForwardingEmailRpcDef<
|
||||||
|
Settings extends ApiSettings,
|
||||||
|
Request extends IntegrationRequest = IntegrationRequest,
|
||||||
|
> = RpcConfiguration<Request, ForwarderContext<Settings>, string>;
|
||||||
|
|
||||||
|
/** definition of the get account id api call for an integration */
|
||||||
|
export type GetAccountIdRpcDef<
|
||||||
|
Settings extends ApiSettings,
|
||||||
|
Request extends IntegrationRequest = IntegrationRequest,
|
||||||
|
> = RpcConfiguration<Request, ForwarderContext<Settings>, string>;
|
||||||
|
|
||||||
|
/** Forwarder-specific static definition */
|
||||||
|
export type ForwarderConfiguration<
|
||||||
|
Settings extends ApiSettings,
|
||||||
|
Request extends IntegrationRequest = IntegrationRequest,
|
||||||
|
> = IntegrationConfiguration & {
|
||||||
|
/** forwarder endpoint definition */
|
||||||
|
forwarder: {
|
||||||
|
/** default value of all fields */
|
||||||
|
defaultSettings: Partial<Settings>;
|
||||||
|
|
||||||
|
/** forwarder settings storage */
|
||||||
|
settings: UserKeyDefinition<Settings>;
|
||||||
|
|
||||||
|
/** forwarder settings import buffer; `undefined` when there is no buffer. */
|
||||||
|
importBuffer?: BufferedKeyDefinition<Settings>;
|
||||||
|
|
||||||
|
/** createForwardingEmail RPC definition */
|
||||||
|
createForwardingEmail: CreateForwardingEmailRpcDef<Settings, Request>;
|
||||||
|
|
||||||
|
/** getAccountId RPC definition; the response updates `accountId` which has a
|
||||||
|
* structural mixin type `RequestAccount`.
|
||||||
|
*/
|
||||||
|
getAccountId?: GetAccountIdRpcDef<Settings, Request>;
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,84 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
|
import { ForwarderConfiguration } from "./forwarder-configuration";
|
||||||
|
import { ForwarderContext } from "./forwarder-context";
|
||||||
|
import { EmailDomainSettings, EmailPrefixSettings } from "./settings";
|
||||||
|
|
||||||
|
describe("ForwarderContext", () => {
|
||||||
|
const i18n = mock<I18nService>({
|
||||||
|
t(key: string) {
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("emailDomain", () => {
|
||||||
|
it("returns the domain", () => {
|
||||||
|
const settings = mock<EmailDomainSettings & ApiSettings>({ domain: "example.com" });
|
||||||
|
const config = mock<ForwarderConfiguration<typeof settings>>();
|
||||||
|
const context = new ForwarderContext(config, settings, i18n);
|
||||||
|
|
||||||
|
const result = context.emailDomain();
|
||||||
|
|
||||||
|
expect(result).toEqual("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([[null], [undefined], [""]])("throws an error if the domain is %p", (domain) => {
|
||||||
|
const settings = mock<EmailDomainSettings & ApiSettings>({ domain });
|
||||||
|
const config = mock<ForwarderConfiguration<typeof settings>>();
|
||||||
|
const context = new ForwarderContext(config, settings, i18n);
|
||||||
|
|
||||||
|
expect(() => context.emailDomain()).toThrow("forwarderNoDomain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws an error if the domain is not an enumerable member of settings", () => {
|
||||||
|
const settings = {} as EmailDomainSettings & ApiSettings;
|
||||||
|
const config = mock<ForwarderConfiguration<typeof settings>>();
|
||||||
|
const context = new ForwarderContext(config, settings, i18n);
|
||||||
|
|
||||||
|
expect(() => context.emailDomain()).toThrow("forwarderNoDomain");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("emailPrefix", () => {
|
||||||
|
it("returns the prefix", () => {
|
||||||
|
const settings = mock<EmailPrefixSettings & ApiSettings>({ prefix: "foo" });
|
||||||
|
const config = mock<ForwarderConfiguration<typeof settings>>();
|
||||||
|
const context = new ForwarderContext(config, settings, i18n);
|
||||||
|
|
||||||
|
const result = context.emailPrefix();
|
||||||
|
|
||||||
|
expect(result).toEqual("foo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([[null], [undefined], [""]])("throws an error if the prefix is %p", (prefix) => {
|
||||||
|
const settings = mock<EmailPrefixSettings & ApiSettings>({ prefix });
|
||||||
|
const config = mock<ForwarderConfiguration<typeof settings>>();
|
||||||
|
const context = new ForwarderContext(config, settings, i18n);
|
||||||
|
|
||||||
|
expect(() => context.emailPrefix()).toThrow("forwarderNoPrefix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws an error if the prefix is not an enumerable member of settings", () => {
|
||||||
|
const settings = {} as EmailPrefixSettings & ApiSettings;
|
||||||
|
const config = mock<ForwarderConfiguration<typeof settings>>();
|
||||||
|
const context = new ForwarderContext(config, settings, i18n);
|
||||||
|
|
||||||
|
expect(() => context.emailPrefix()).toThrow("forwarderNoPrefix");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("missingAccountIdCause", () => {
|
||||||
|
it("returns the cause", () => {
|
||||||
|
const settings = mock<EmailDomainSettings & ApiSettings>();
|
||||||
|
const config = mock<ForwarderConfiguration<typeof settings>>();
|
||||||
|
const context = new ForwarderContext(config, settings, i18n);
|
||||||
|
|
||||||
|
const result = context.missingAccountIdCause();
|
||||||
|
|
||||||
|
expect(result).toEqual("forwarderNoAccountId");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
63
libs/tools/generator/core/src/engine/forwarder-context.ts
Normal file
63
libs/tools/generator/core/src/engine/forwarder-context.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { IntegrationContext } from "@bitwarden/common/tools/integration/integration-context";
|
||||||
|
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
|
import { ForwarderConfiguration } from "./forwarder-configuration";
|
||||||
|
import { EmailDomainSettings, EmailPrefixSettings } from "./settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surfaces contextual information to forwarder integrations.
|
||||||
|
*/
|
||||||
|
export class ForwarderContext<Settings extends ApiSettings> extends IntegrationContext<Settings> {
|
||||||
|
/** Instantiates the context.
|
||||||
|
* @param configuration of the forwarder this context assists
|
||||||
|
* @param settings loaded from the forwarder's state
|
||||||
|
* @param i18n localizes error handling
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
readonly configuration: ForwarderConfiguration<Settings>,
|
||||||
|
settings: Settings,
|
||||||
|
i18n: I18nService,
|
||||||
|
) {
|
||||||
|
super(configuration, settings, i18n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** look up the domain part of an email address from the forwarder's settings.
|
||||||
|
* @returns a domain part of an email address
|
||||||
|
* @throws a localized error message when the domain isn't found.
|
||||||
|
* @remarks the string is thrown for backwards compatibility
|
||||||
|
*/
|
||||||
|
emailDomain(): Settings extends EmailDomainSettings ? string : never {
|
||||||
|
const domain = "domain" in this.settings ? (this.settings.domain ?? "") : "";
|
||||||
|
if (domain === "") {
|
||||||
|
const error = this.i18n.t("forwarderNoDomain", this.configuration.name);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** look up a prefix applied to the email address from the forwarder's settings.
|
||||||
|
* @returns the prefix
|
||||||
|
* @throws a localized error message when the prefix isn't found.
|
||||||
|
* @remarks the string is thrown for backwards compatibility
|
||||||
|
*/
|
||||||
|
emailPrefix(): Settings extends EmailPrefixSettings ? string : never {
|
||||||
|
const prefix = "prefix" in this.settings ? (this.settings.prefix ?? "") : "";
|
||||||
|
if (prefix === "") {
|
||||||
|
const error = this.i18n.t("forwarderNoPrefix", this.configuration.name);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** look up a localized error message indicating an account id is required
|
||||||
|
* but wasn't found.
|
||||||
|
* @remarks this returns a string instead of throwing it so that the
|
||||||
|
* user can decide upon control flow.
|
||||||
|
*/
|
||||||
|
missingAccountIdCause() {
|
||||||
|
return this.i18n.t("forwarderNoAccountId", this.configuration.name);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,7 @@
|
|||||||
export { CryptoServiceRandomizer } from "./crypto-service-randomizer";
|
export { CryptoServiceRandomizer } from "./crypto-service-randomizer";
|
||||||
|
export { ForwarderConfiguration, AccountRequest } from "./forwarder-configuration";
|
||||||
|
export { ForwarderContext } from "./forwarder-context";
|
||||||
|
export * from "./settings";
|
||||||
export { EmailRandomizer } from "./email-randomizer";
|
export { EmailRandomizer } from "./email-randomizer";
|
||||||
export { EmailCalculator } from "./email-calculator";
|
export { EmailCalculator } from "./email-calculator";
|
||||||
export { PasswordRandomizer } from "./password-randomizer";
|
export { PasswordRandomizer } from "./password-randomizer";
|
||||||
|
@ -0,0 +1,117 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ApiSettings,
|
||||||
|
IntegrationRequest,
|
||||||
|
TokenHeader,
|
||||||
|
} from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
|
import { CreateForwardingEmailRpcDef, ForwarderConfiguration } from "../forwarder-configuration";
|
||||||
|
import { ForwarderContext } from "../forwarder-context";
|
||||||
|
|
||||||
|
import { CreateForwardingAddressRpc } from "./create-forwarding-address";
|
||||||
|
|
||||||
|
describe("CreateForwardingAddressRpc", () => {
|
||||||
|
const createForwardingEmail =
|
||||||
|
mock<CreateForwardingEmailRpcDef<ApiSettings, IntegrationRequest>>();
|
||||||
|
const requestor = mock<ForwarderConfiguration<ApiSettings>>({
|
||||||
|
forwarder: { createForwardingEmail },
|
||||||
|
});
|
||||||
|
const context = mock<ForwarderContext<ApiSettings>>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createForwardingEmail.url.mockReturnValue("https://httpbin.org/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toRequest", () => {
|
||||||
|
it("constructs a request", () => {
|
||||||
|
const builder = new CreateForwardingAddressRpc(requestor, context);
|
||||||
|
|
||||||
|
const result = builder.toRequest({ website: null });
|
||||||
|
|
||||||
|
expect(result.redirect).toEqual("manual");
|
||||||
|
expect(result.cache).toEqual("no-store");
|
||||||
|
expect(result.method).toEqual("POST");
|
||||||
|
expect(result.headers.get("Content-Type")).toEqual("application/json");
|
||||||
|
expect(result.headers.get("X-Requested-With")).toEqual("XMLHttpRequest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides the request and context to the rpc definition functions", () => {
|
||||||
|
const builder = new CreateForwardingAddressRpc(requestor, context);
|
||||||
|
const request: IntegrationRequest = { website: null };
|
||||||
|
|
||||||
|
builder.toRequest(request);
|
||||||
|
|
||||||
|
expect(requestor.authenticate).toHaveBeenCalledWith(request, context);
|
||||||
|
|
||||||
|
expect(createForwardingEmail.url).toHaveBeenCalledWith(request, context);
|
||||||
|
expect(createForwardingEmail.body).toHaveBeenCalledWith(request, context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stringifies the body", async () => {
|
||||||
|
createForwardingEmail.body.mockReturnValue({ foo: 1 });
|
||||||
|
const builder = new CreateForwardingAddressRpc(requestor, context);
|
||||||
|
|
||||||
|
const request = builder.toRequest({ website: null });
|
||||||
|
|
||||||
|
// extract the text from the body; it's wild there isn't
|
||||||
|
// a more clear way to do this
|
||||||
|
const result = await new Response(request.body).text();
|
||||||
|
|
||||||
|
expect(result).toEqual('{"foo":1}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the body", async () => {
|
||||||
|
// can't use the mock here because it defines a `body` function
|
||||||
|
// on `createForwardingEmail`
|
||||||
|
const requestor = {
|
||||||
|
authenticate() {
|
||||||
|
return undefined as TokenHeader;
|
||||||
|
},
|
||||||
|
forwarder: {
|
||||||
|
createForwardingEmail: {
|
||||||
|
url() {
|
||||||
|
return "https://httpbin.org/json";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ForwarderConfiguration<ApiSettings>;
|
||||||
|
|
||||||
|
const builder = new CreateForwardingAddressRpc(requestor, context);
|
||||||
|
|
||||||
|
const result = builder.toRequest({ website: null });
|
||||||
|
|
||||||
|
expect(result.body).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasJsonPayload", () => {
|
||||||
|
it("forwards the call to the rpc definition with context", () => {
|
||||||
|
const builder = new CreateForwardingAddressRpc(requestor, context);
|
||||||
|
const response: Response = {} as any;
|
||||||
|
createForwardingEmail.hasJsonPayload.mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = builder.hasJsonPayload(response);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(createForwardingEmail.hasJsonPayload).toHaveBeenCalledWith(response, context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processJson", () => {
|
||||||
|
it("forwards the call to the rpc definition with context", () => {
|
||||||
|
const builder = new CreateForwardingAddressRpc(requestor, context);
|
||||||
|
const json = {} as any;
|
||||||
|
createForwardingEmail.processJson.mockReturnValue(["foo"]);
|
||||||
|
|
||||||
|
const result = builder.processJson(json);
|
||||||
|
|
||||||
|
expect(result).toEqual(["foo"]);
|
||||||
|
expect(createForwardingEmail.processJson).toHaveBeenCalledWith(json, context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,64 @@
|
|||||||
|
import { IntegrationContext } from "@bitwarden/common/tools/integration";
|
||||||
|
import { JsonRpc, IntegrationRequest, ApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
|
import { ForwarderConfiguration } from "../forwarder-configuration";
|
||||||
|
import { ForwarderContext } from "../forwarder-context";
|
||||||
|
|
||||||
|
export class CreateForwardingAddressRpc<
|
||||||
|
Settings extends ApiSettings,
|
||||||
|
Req extends IntegrationRequest = IntegrationRequest,
|
||||||
|
> implements JsonRpc<Req, string>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
readonly requestor: ForwarderConfiguration<Settings>,
|
||||||
|
readonly context: ForwarderContext<Settings>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get createForwardingEmail() {
|
||||||
|
return this.requestor.forwarder.createForwardingEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
toRequest(req: Req) {
|
||||||
|
const url = this.createForwardingEmail.url(req, this.context);
|
||||||
|
const token = this.requestor.authenticate(req, this.context as IntegrationContext<Settings>);
|
||||||
|
const body = this.body(req);
|
||||||
|
|
||||||
|
const request = new Request(url, {
|
||||||
|
redirect: "manual",
|
||||||
|
cache: "no-store",
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({
|
||||||
|
...token,
|
||||||
|
// X-Requested-With header required by some endpoints for
|
||||||
|
// detailed error descriptions (see #5565)
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private body(req: Req) {
|
||||||
|
const toBody = this.createForwardingEmail.body;
|
||||||
|
if (!toBody) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = toBody(req, this.context);
|
||||||
|
if (!body) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasJsonPayload(response: Response): boolean {
|
||||||
|
return this.createForwardingEmail.hasJsonPayload(response, this.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
processJson(json: any): [string?, string?] {
|
||||||
|
return this.createForwardingEmail.processJson(json, this.context);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,83 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
|
import { GetAccountIdRpcDef, ForwarderConfiguration } from "../forwarder-configuration";
|
||||||
|
import { ForwarderContext } from "../forwarder-context";
|
||||||
|
|
||||||
|
import { GetAccountIdRpc } from "./get-account-id";
|
||||||
|
|
||||||
|
describe("GetAccountIdRpc", () => {
|
||||||
|
const getAccountId = mock<GetAccountIdRpcDef<ApiSettings, IntegrationRequest>>();
|
||||||
|
const requestor = mock<ForwarderConfiguration<ApiSettings>>({
|
||||||
|
forwarder: { getAccountId },
|
||||||
|
});
|
||||||
|
const context = mock<ForwarderContext<ApiSettings>>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getAccountId.url.mockReturnValue("https://httpbin.org/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toRequest", () => {
|
||||||
|
it("constructs a request", () => {
|
||||||
|
const builder = new GetAccountIdRpc(requestor, context);
|
||||||
|
|
||||||
|
const result = builder.toRequest({ website: null });
|
||||||
|
|
||||||
|
expect(result.redirect).toEqual("manual");
|
||||||
|
expect(result.cache).toEqual("no-store");
|
||||||
|
expect(result.method).toEqual("GET");
|
||||||
|
expect(result.headers.get("Content-Type")).toEqual("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides the request and context to the rpc definition functions", () => {
|
||||||
|
const builder = new GetAccountIdRpc(requestor, context);
|
||||||
|
const request: IntegrationRequest = { website: null };
|
||||||
|
|
||||||
|
builder.toRequest(request);
|
||||||
|
|
||||||
|
expect(requestor.authenticate).toHaveBeenCalledWith(request, context);
|
||||||
|
|
||||||
|
expect(getAccountId.url).toHaveBeenCalledWith(request, context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the body", async () => {
|
||||||
|
const builder = new GetAccountIdRpc(requestor, context);
|
||||||
|
|
||||||
|
const result = builder.toRequest({ website: null });
|
||||||
|
|
||||||
|
expect(result.body).toBeNull();
|
||||||
|
expect(getAccountId.body).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasJsonPayload", () => {
|
||||||
|
it("forwards the call to the rpc definition with context", () => {
|
||||||
|
const builder = new GetAccountIdRpc(requestor, context);
|
||||||
|
const response: Response = {} as any;
|
||||||
|
getAccountId.hasJsonPayload.mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = builder.hasJsonPayload(response);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(getAccountId.hasJsonPayload).toHaveBeenCalledWith(response, context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processJson", () => {
|
||||||
|
it("forwards the call to the rpc definition with context", () => {
|
||||||
|
const builder = new GetAccountIdRpc(requestor, context);
|
||||||
|
const json = {} as any;
|
||||||
|
getAccountId.processJson.mockReturnValue(["foo"]);
|
||||||
|
|
||||||
|
const result = builder.processJson(json);
|
||||||
|
|
||||||
|
expect(result).toEqual(["foo"]);
|
||||||
|
expect(getAccountId.processJson).toHaveBeenCalledWith(json, context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
41
libs/tools/generator/core/src/engine/rpc/get-account-id.ts
Normal file
41
libs/tools/generator/core/src/engine/rpc/get-account-id.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { IntegrationContext } from "@bitwarden/common/tools/integration";
|
||||||
|
import { JsonRpc, IntegrationRequest, ApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
|
import { ForwarderConfiguration } from "../forwarder-configuration";
|
||||||
|
import { ForwarderContext } from "../forwarder-context";
|
||||||
|
|
||||||
|
export class GetAccountIdRpc<
|
||||||
|
Settings extends ApiSettings,
|
||||||
|
Req extends IntegrationRequest = IntegrationRequest,
|
||||||
|
> implements JsonRpc<Req, string>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
readonly requestor: ForwarderConfiguration<Settings>,
|
||||||
|
readonly context: ForwarderContext<Settings>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
hasJsonPayload(response: Response) {
|
||||||
|
return this.requestor.forwarder.getAccountId.hasJsonPayload(response, this.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
processJson(json: any) {
|
||||||
|
return this.requestor.forwarder.getAccountId.processJson(json, this.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
toRequest(req: Req) {
|
||||||
|
const url = this.requestor.forwarder.getAccountId.url(req, this.context);
|
||||||
|
const token = this.requestor.authenticate(req, this.context as IntegrationContext<Settings>);
|
||||||
|
|
||||||
|
const request = new Request(url, {
|
||||||
|
redirect: "manual",
|
||||||
|
cache: "no-store",
|
||||||
|
method: "GET",
|
||||||
|
headers: new Headers({
|
||||||
|
...token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
2
libs/tools/generator/core/src/engine/rpc/index.ts
Normal file
2
libs/tools/generator/core/src/engine/rpc/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./create-forwarding-address";
|
||||||
|
export * from "./get-account-id";
|
20
libs/tools/generator/core/src/engine/settings.ts
Normal file
20
libs/tools/generator/core/src/engine/settings.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/** Api configuration for forwarders that support custom domains. */
|
||||||
|
export type EmailDomainSettings = {
|
||||||
|
/** The domain part of the generated email address.
|
||||||
|
* @remarks The domain should be authorized by the forwarder before
|
||||||
|
* submitting a request through bitwarden.
|
||||||
|
* @example If the domain is `domain.io` and the generated username
|
||||||
|
* is `jd`, then the generated email address will be `jd@domain.io`
|
||||||
|
*/
|
||||||
|
domain: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Api configuration for forwarders that support custom email parts. */
|
||||||
|
export type EmailPrefixSettings = {
|
||||||
|
/** A prefix joined to the generated email address' username.
|
||||||
|
* @example If the prefix is `foo`, the generated username is `bar`,
|
||||||
|
* and the domain is `domain.io`, then the generated email address is `
|
||||||
|
* then the generated username is `foobar@domain.io`.
|
||||||
|
*/
|
||||||
|
prefix: string;
|
||||||
|
};
|
@ -2,6 +2,7 @@ export * from "./abstractions";
|
|||||||
export * from "./data";
|
export * from "./data";
|
||||||
export { createRandomizer } from "./factories";
|
export { createRandomizer } from "./factories";
|
||||||
export * as engine from "./engine";
|
export * as engine from "./engine";
|
||||||
|
export * as integration from "./integration";
|
||||||
export * as policies from "./policies";
|
export * as policies from "./policies";
|
||||||
export * as rx from "./rx";
|
export * as rx from "./rx";
|
||||||
export * as services from "./services";
|
export * as services from "./services";
|
||||||
|
83
libs/tools/generator/core/src/integration/addy-io.spec.ts
Normal file
83
libs/tools/generator/core/src/integration/addy-io.spec.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { ForwarderContext } from "../engine";
|
||||||
|
|
||||||
|
import { AddyIo, AddyIoSettings } from "./addy-io";
|
||||||
|
|
||||||
|
describe("Addy.io forwarder", () => {
|
||||||
|
const context = mock<ForwarderContext<AddyIoSettings>>();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authenticate", () => {
|
||||||
|
it("returns a bearer header with the token", () => {
|
||||||
|
context.authenticationToken.mockReturnValue("token");
|
||||||
|
const result = AddyIo.authenticate(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual({ Authorization: "Bearer token" });
|
||||||
|
expect(context.authenticationToken).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = AddyIo.forwarder.settings.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("importBuffer", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = AddyIo.forwarder.importBuffer.options.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createForwardingEmail", () => {
|
||||||
|
describe("url", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.baseUrl.mockReturnValue("");
|
||||||
|
|
||||||
|
const result = AddyIo.forwarder.createForwardingEmail.url(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual("/api/v1/aliases");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("body", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.emailDomain.mockReturnValue("domain");
|
||||||
|
context.generatedBy.mockReturnValue("generated by");
|
||||||
|
|
||||||
|
const result = AddyIo.forwarder.createForwardingEmail.body(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
domain: "domain",
|
||||||
|
description: "generated by",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasJsonPayload", () => {
|
||||||
|
it.each([[200], [201]])("returns true when the status is $%i", (status) => {
|
||||||
|
const result = AddyIo.forwarder.createForwardingEmail.hasJsonPayload(
|
||||||
|
{ status } as Response,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processJson", () => {
|
||||||
|
it("should read the email from the response", () => {
|
||||||
|
const json = { data: { email: "foo@example.com" } };
|
||||||
|
const result = AddyIo.forwarder.createForwardingEmail.processJson(json, context);
|
||||||
|
expect(result).toEqual(["foo@example.com"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
73
libs/tools/generator/core/src/integration/addy-io.ts
Normal file
73
libs/tools/generator/core/src/integration/addy-io.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
|
import {
|
||||||
|
ApiSettings,
|
||||||
|
IntegrationRequest,
|
||||||
|
SelfHostedApiSettings,
|
||||||
|
} from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
|
||||||
|
import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine";
|
||||||
|
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
||||||
|
import { EmailDomainOptions, SelfHostedApiOptions } from "../types";
|
||||||
|
|
||||||
|
// integration types
|
||||||
|
export type AddyIoSettings = SelfHostedApiSettings & EmailDomainSettings;
|
||||||
|
export type AddyIoOptions = SelfHostedApiOptions & EmailDomainOptions;
|
||||||
|
export type AddyIoConfiguration = ForwarderConfiguration<AddyIoSettings>;
|
||||||
|
|
||||||
|
// default values
|
||||||
|
const defaultSettings = Object.freeze({
|
||||||
|
token: "",
|
||||||
|
domain: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// supported RPC calls
|
||||||
|
const createForwardingEmail = Object.freeze({
|
||||||
|
url(_request: IntegrationRequest, context: ForwarderContext<AddyIoSettings>) {
|
||||||
|
return context.baseUrl() + "/api/v1/aliases";
|
||||||
|
},
|
||||||
|
body(request: IntegrationRequest, context: ForwarderContext<AddyIoSettings>) {
|
||||||
|
return {
|
||||||
|
domain: context.emailDomain(),
|
||||||
|
description: context.generatedBy(request),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hasJsonPayload(response: Response) {
|
||||||
|
return response.status === 200 || response.status === 201;
|
||||||
|
},
|
||||||
|
processJson(json: any) {
|
||||||
|
return [json?.data?.email];
|
||||||
|
},
|
||||||
|
} as CreateForwardingEmailRpcDef<AddyIoSettings>);
|
||||||
|
|
||||||
|
// forwarder configuration
|
||||||
|
const forwarder = Object.freeze({
|
||||||
|
defaultSettings,
|
||||||
|
settings: new UserKeyDefinition<AddyIoSettings>(GENERATOR_DISK, "addyIoForwarder", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: [],
|
||||||
|
}),
|
||||||
|
importBuffer: new BufferedKeyDefinition<AddyIoSettings>(GENERATOR_DISK, "addyIoBuffer", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
}),
|
||||||
|
createForwardingEmail,
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
export const AddyIo = Object.freeze({
|
||||||
|
// integration
|
||||||
|
id: "anonaddy" as IntegrationId,
|
||||||
|
name: "Addy.io",
|
||||||
|
extends: ["forwarder"],
|
||||||
|
|
||||||
|
// hosting
|
||||||
|
selfHost: "maybe",
|
||||||
|
baseUrl: "https://app.addy.io",
|
||||||
|
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
|
||||||
|
return { Authorization: "Bearer " + context.authenticationToken() };
|
||||||
|
},
|
||||||
|
|
||||||
|
// extensions
|
||||||
|
forwarder,
|
||||||
|
} as AddyIoConfiguration);
|
@ -0,0 +1,78 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { ForwarderContext } from "../engine";
|
||||||
|
|
||||||
|
import { DuckDuckGo, DuckDuckGoSettings } from "./duck-duck-go";
|
||||||
|
|
||||||
|
describe("DuckDuckGo forwarder", () => {
|
||||||
|
const context = mock<ForwarderContext<DuckDuckGoSettings>>();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authenticate", () => {
|
||||||
|
it("returns a bearer header with the token", () => {
|
||||||
|
context.authenticationToken.mockReturnValue("token");
|
||||||
|
|
||||||
|
const result = DuckDuckGo.authenticate(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual({ Authorization: "Bearer token" });
|
||||||
|
expect(context.authenticationToken).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = DuckDuckGo.forwarder.settings.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("importBuffer", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = DuckDuckGo.forwarder.importBuffer.options.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createForwardingEmail", () => {
|
||||||
|
describe("url", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.baseUrl.mockReturnValue("");
|
||||||
|
|
||||||
|
const result = DuckDuckGo.forwarder.createForwardingEmail.url(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual("/email/addresses");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("body", () => {
|
||||||
|
it("returns undefined", () => {
|
||||||
|
const result = DuckDuckGo.forwarder.createForwardingEmail.body(null, context);
|
||||||
|
|
||||||
|
expect(result).not.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasJsonPayload", () => {
|
||||||
|
it.each([[200], [201]])("returns true when the status is $%i", (status) => {
|
||||||
|
const result = DuckDuckGo.forwarder.createForwardingEmail.hasJsonPayload(
|
||||||
|
{ status } as Response,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processJson", () => {
|
||||||
|
it("should read the email from the response", () => {
|
||||||
|
const json = { address: "foo" };
|
||||||
|
const result = DuckDuckGo.forwarder.createForwardingEmail.processJson(json, context);
|
||||||
|
expect(result).toEqual(["foo@duck.com"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
61
libs/tools/generator/core/src/integration/duck-duck-go.ts
Normal file
61
libs/tools/generator/core/src/integration/duck-duck-go.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
|
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
|
||||||
|
import { ForwarderConfiguration, ForwarderContext } from "../engine";
|
||||||
|
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
||||||
|
import { ApiOptions } from "../types";
|
||||||
|
|
||||||
|
// integration types
|
||||||
|
export type DuckDuckGoSettings = ApiSettings;
|
||||||
|
export type DuckDuckGoOptions = ApiOptions;
|
||||||
|
export type DuckDuckGoConfiguration = ForwarderConfiguration<DuckDuckGoSettings>;
|
||||||
|
|
||||||
|
// default values
|
||||||
|
const defaultSettings = Object.freeze({
|
||||||
|
token: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// supported RPC calls
|
||||||
|
const createForwardingEmail = Object.freeze({
|
||||||
|
url(_request: IntegrationRequest, context: ForwarderContext<DuckDuckGoSettings>) {
|
||||||
|
return context.baseUrl() + "/email/addresses";
|
||||||
|
},
|
||||||
|
body(_request: IntegrationRequest, _context: ForwarderContext<DuckDuckGoSettings>) {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
hasJsonPayload(response: Response) {
|
||||||
|
return response.status === 200 || response.status === 201;
|
||||||
|
},
|
||||||
|
processJson(json: any) {
|
||||||
|
return [`${json.address}@duck.com`];
|
||||||
|
},
|
||||||
|
} as CreateForwardingEmailRpcDef<DuckDuckGoSettings>);
|
||||||
|
|
||||||
|
// forwarder configuration
|
||||||
|
const forwarder = Object.freeze({
|
||||||
|
defaultSettings,
|
||||||
|
settings: new UserKeyDefinition<DuckDuckGoSettings>(GENERATOR_DISK, "duckDuckGoForwarder", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: [],
|
||||||
|
}),
|
||||||
|
importBuffer: new BufferedKeyDefinition<DuckDuckGoSettings>(GENERATOR_DISK, "duckDuckGoBuffer", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
}),
|
||||||
|
createForwardingEmail,
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
// integration-wide configuration
|
||||||
|
export const DuckDuckGo = Object.freeze({
|
||||||
|
id: "duckduckgo" as IntegrationId,
|
||||||
|
name: "DuckDuckGo",
|
||||||
|
baseUrl: "https://quack.duckduckgo.com/api",
|
||||||
|
selfHost: "never",
|
||||||
|
extends: ["forwarder"],
|
||||||
|
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
|
||||||
|
return { Authorization: "Bearer " + context.authenticationToken() };
|
||||||
|
},
|
||||||
|
forwarder,
|
||||||
|
} as DuckDuckGoConfiguration);
|
215
libs/tools/generator/core/src/integration/fastmail.spec.ts
Normal file
215
libs/tools/generator/core/src/integration/fastmail.spec.ts
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { ForwarderContext } from "../engine";
|
||||||
|
|
||||||
|
import { Fastmail, FastmailSettings } from "./fastmail";
|
||||||
|
|
||||||
|
describe("Fastmail forwarder", () => {
|
||||||
|
const context = mock<ForwarderContext<FastmailSettings>>();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authenticate", () => {
|
||||||
|
it("returns a bearer header with the token", () => {
|
||||||
|
context.authenticationToken.mockReturnValue("token");
|
||||||
|
|
||||||
|
const result = Fastmail.authenticate(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual({ Authorization: "Bearer token" });
|
||||||
|
expect(context.authenticationToken).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = Fastmail.forwarder.settings.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("importBuffer", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = Fastmail.forwarder.importBuffer.options.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAccountId", () => {
|
||||||
|
describe("url", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.baseUrl.mockReturnValue("");
|
||||||
|
|
||||||
|
const result = Fastmail.forwarder.getAccountId.url(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual("/jmap/session");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasJsonPayload", () => {
|
||||||
|
it.each([[200]])("returns true when the status is $%i", (status) => {
|
||||||
|
const result = Fastmail.forwarder.getAccountId.hasJsonPayload(
|
||||||
|
{ status } as Response,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processJson", () => {
|
||||||
|
it("looks up an account id", () => {
|
||||||
|
const json = {
|
||||||
|
primaryAccounts: {
|
||||||
|
"https://www.fastmail.com/dev/maskedemail": "some id",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = Fastmail.forwarder.getAccountId.processJson(json, context);
|
||||||
|
expect(result).toEqual(["some id"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a cause when account id is missing", () => {
|
||||||
|
context.missingAccountIdCause.mockReturnValue("cause");
|
||||||
|
const json = {
|
||||||
|
primaryAccounts: {
|
||||||
|
"https://www.fastmail.com/dev/maskedemail": null as string,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = Fastmail.forwarder.getAccountId.processJson(json, context);
|
||||||
|
expect(result).toEqual([undefined, "cause"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a cause when masked mail account is missing", () => {
|
||||||
|
context.missingAccountIdCause.mockReturnValue("cause");
|
||||||
|
const json = { primaryAccounts: {} };
|
||||||
|
|
||||||
|
const result = Fastmail.forwarder.getAccountId.processJson(json, context);
|
||||||
|
expect(result).toEqual([undefined, "cause"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a cause when all accounts are missing", () => {
|
||||||
|
context.missingAccountIdCause.mockReturnValue("cause");
|
||||||
|
const json = { primaryAccounts: null as any };
|
||||||
|
|
||||||
|
const result = Fastmail.forwarder.getAccountId.processJson(json, context);
|
||||||
|
expect(result).toEqual([undefined, "cause"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createForwardingEmail", () => {
|
||||||
|
describe("url", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.baseUrl.mockReturnValue("");
|
||||||
|
|
||||||
|
const result = Fastmail.forwarder.createForwardingEmail.url(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual("/jmap/api/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("body", () => {
|
||||||
|
it("creates a request body", () => {
|
||||||
|
context.website.mockReturnValue("website");
|
||||||
|
const request = { accountId: "accountId", website: "" };
|
||||||
|
|
||||||
|
const result = Fastmail.forwarder.createForwardingEmail.body(request, context);
|
||||||
|
const methodCall = result.methodCalls[0][1];
|
||||||
|
|
||||||
|
expect(methodCall.accountId).toEqual("accountId");
|
||||||
|
expect(methodCall.create["new-masked-email"].forDomain).toEqual("website");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasJsonPayload", () => {
|
||||||
|
it.each([[200]])("returns true when the status is $%i", (status) => {
|
||||||
|
const result = Fastmail.forwarder.createForwardingEmail.hasJsonPayload(
|
||||||
|
{ status } as Response,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processJson", () => {
|
||||||
|
it("returns the generated email address", () => {
|
||||||
|
const body = {
|
||||||
|
methodResponses: [
|
||||||
|
[
|
||||||
|
"MaskedEmail/set",
|
||||||
|
{
|
||||||
|
created: {
|
||||||
|
"new-masked-email": {
|
||||||
|
email: "jdoe@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = Fastmail.forwarder.createForwardingEmail.processJson(body, context);
|
||||||
|
|
||||||
|
expect(result).toEqual(["jdoe@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a forwarder error if masked email creation fails", () => {
|
||||||
|
const notCreatedBody = {
|
||||||
|
methodResponses: [
|
||||||
|
[
|
||||||
|
"MaskedEmail/set",
|
||||||
|
{
|
||||||
|
notCreated: { "new-masked-email": { description: "It turned inside out!" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const notCreatedResult = Fastmail.forwarder.createForwardingEmail.processJson(
|
||||||
|
notCreatedBody,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notCreatedResult).toEqual([undefined, "It turned inside out!"]);
|
||||||
|
|
||||||
|
const generalErrorBody = {
|
||||||
|
methodResponses: [["error", { description: "And then it exploded!" }]],
|
||||||
|
};
|
||||||
|
|
||||||
|
const generalErrorResult = Fastmail.forwarder.createForwardingEmail.processJson(
|
||||||
|
generalErrorBody,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(generalErrorResult).toEqual([undefined, "And then it exploded!"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 } }]],
|
||||||
|
])("returns undefined if the jmap request is malformed (=%p)", (response: any) => {
|
||||||
|
const generalErrorBody = {
|
||||||
|
methodResponses: response,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = Fastmail.forwarder.createForwardingEmail.processJson(
|
||||||
|
generalErrorBody,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
127
libs/tools/generator/core/src/integration/fastmail.ts
Normal file
127
libs/tools/generator/core/src/integration/fastmail.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
|
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ForwarderConfiguration,
|
||||||
|
ForwarderContext,
|
||||||
|
EmailDomainSettings,
|
||||||
|
AccountRequest,
|
||||||
|
EmailPrefixSettings,
|
||||||
|
} from "../engine";
|
||||||
|
import { CreateForwardingEmailRpcDef, GetAccountIdRpcDef } from "../engine/forwarder-configuration";
|
||||||
|
import { ApiOptions, EmailPrefixOptions } from "../types";
|
||||||
|
|
||||||
|
// integration types
|
||||||
|
export type FastmailSettings = ApiSettings & EmailPrefixSettings & EmailDomainSettings;
|
||||||
|
export type FastmailOptions = ApiOptions & EmailPrefixOptions & AccountRequest;
|
||||||
|
export type FastmailRequest = IntegrationRequest & AccountRequest;
|
||||||
|
export type FastmailConfiguration = ForwarderConfiguration<FastmailSettings, FastmailRequest>;
|
||||||
|
|
||||||
|
// default values
|
||||||
|
const defaultSettings = Object.freeze({
|
||||||
|
domain: "",
|
||||||
|
prefix: "",
|
||||||
|
token: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// supported RPC calls
|
||||||
|
const getAccountId = Object.freeze({
|
||||||
|
url(_request: IntegrationRequest, context: ForwarderContext<FastmailSettings>) {
|
||||||
|
// cannot use "/.well-known/jmap" because integration RPCs
|
||||||
|
// never follow redirects
|
||||||
|
return context.baseUrl() + "/jmap/session";
|
||||||
|
},
|
||||||
|
hasJsonPayload(response: Response) {
|
||||||
|
return response.status === 200;
|
||||||
|
},
|
||||||
|
processJson(json: any, context: ForwarderContext<FastmailSettings>) {
|
||||||
|
const result = json.primaryAccounts?.["https://www.fastmail.com/dev/maskedemail"] ?? undefined;
|
||||||
|
|
||||||
|
return [result, result ? undefined : context.missingAccountIdCause()];
|
||||||
|
},
|
||||||
|
} as GetAccountIdRpcDef<FastmailSettings>);
|
||||||
|
|
||||||
|
const createForwardingEmail = Object.freeze({
|
||||||
|
url(_request: IntegrationRequest, context: ForwarderContext<FastmailSettings>) {
|
||||||
|
return context.baseUrl() + "/jmap/api/";
|
||||||
|
},
|
||||||
|
body(request: FastmailRequest, context: ForwarderContext<FastmailSettings>) {
|
||||||
|
const body = {
|
||||||
|
using: ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
"MaskedEmail/set",
|
||||||
|
{
|
||||||
|
accountId: request.accountId,
|
||||||
|
create: {
|
||||||
|
"new-masked-email": {
|
||||||
|
state: "enabled",
|
||||||
|
description: "",
|
||||||
|
forDomain: context.website(request),
|
||||||
|
emailPrefix: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"0",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
hasJsonPayload(response: Response) {
|
||||||
|
return response.status === 200;
|
||||||
|
},
|
||||||
|
processJson(json: any): [string?, string?] {
|
||||||
|
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) {
|
||||||
|
const email: string = json.methodResponses[0][1]?.created?.["new-masked-email"]?.email;
|
||||||
|
return [email];
|
||||||
|
}
|
||||||
|
if (json.methodResponses[0][1]?.notCreated?.["new-masked-email"] != null) {
|
||||||
|
const errorDescription: string =
|
||||||
|
json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description;
|
||||||
|
return [undefined, errorDescription];
|
||||||
|
}
|
||||||
|
} else if (json.methodResponses[0][0] === "error") {
|
||||||
|
const errorDescription: string = json.methodResponses[0][1]?.description;
|
||||||
|
return [undefined, errorDescription];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as CreateForwardingEmailRpcDef<FastmailSettings, FastmailRequest>);
|
||||||
|
|
||||||
|
// forwarder configuration
|
||||||
|
const forwarder = Object.freeze({
|
||||||
|
defaultSettings,
|
||||||
|
settings: new UserKeyDefinition<FastmailSettings>(GENERATOR_DISK, "fastmailForwarder", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: [],
|
||||||
|
}),
|
||||||
|
importBuffer: new BufferedKeyDefinition<FastmailSettings>(GENERATOR_DISK, "fastmailBuffer", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
}),
|
||||||
|
createForwardingEmail,
|
||||||
|
getAccountId,
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
// integration-wide configuration
|
||||||
|
export const Fastmail = Object.freeze({
|
||||||
|
id: "fastmail" as IntegrationId,
|
||||||
|
name: "Fastmail",
|
||||||
|
baseUrl: "https://api.fastmail.com",
|
||||||
|
selfHost: "maybe",
|
||||||
|
extends: ["forwarder"],
|
||||||
|
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
|
||||||
|
return { Authorization: "Bearer " + context.authenticationToken() };
|
||||||
|
},
|
||||||
|
forwarder,
|
||||||
|
} as FastmailConfiguration);
|
@ -0,0 +1,85 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { ForwarderContext } from "../engine";
|
||||||
|
|
||||||
|
import { FirefoxRelay, FirefoxRelaySettings } from "./firefox-relay";
|
||||||
|
|
||||||
|
describe("Firefox Relay forwarder", () => {
|
||||||
|
const context = mock<ForwarderContext<FirefoxRelaySettings>>();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authenticate", () => {
|
||||||
|
it("returns a token header", () => {
|
||||||
|
context.authenticationToken.mockReturnValue("token");
|
||||||
|
|
||||||
|
const result = FirefoxRelay.authenticate(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual({ Authorization: "Token token" });
|
||||||
|
expect(context.authenticationToken).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = FirefoxRelay.forwarder.settings.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("importBuffer", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = FirefoxRelay.forwarder.importBuffer.options.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createForwardingEmail", () => {
|
||||||
|
describe("url", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.baseUrl.mockReturnValue("");
|
||||||
|
|
||||||
|
const result = FirefoxRelay.forwarder.createForwardingEmail.url(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual("/v1/relayaddresses/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("body", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.website.mockReturnValue("website");
|
||||||
|
context.generatedBy.mockReturnValue("generated by");
|
||||||
|
|
||||||
|
const result = FirefoxRelay.forwarder.createForwardingEmail.body(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
generated_for: "website",
|
||||||
|
description: "generated by",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasJsonPayload", () => {
|
||||||
|
it.each([[200], [201]])("returns true when the status is $%i", (status) => {
|
||||||
|
const result = FirefoxRelay.forwarder.createForwardingEmail.hasJsonPayload(
|
||||||
|
{ status } as Response,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processJson", () => {
|
||||||
|
it("should read the email from the response", () => {
|
||||||
|
const json = { full_address: "foo@example.com" };
|
||||||
|
const result = FirefoxRelay.forwarder.createForwardingEmail.processJson(json, context);
|
||||||
|
expect(result).toEqual(["foo@example.com"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
69
libs/tools/generator/core/src/integration/firefox-relay.ts
Normal file
69
libs/tools/generator/core/src/integration/firefox-relay.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
|
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
|
||||||
|
import { ForwarderConfiguration, ForwarderContext } from "../engine";
|
||||||
|
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
||||||
|
import { ApiOptions } from "../types";
|
||||||
|
|
||||||
|
// integration types
|
||||||
|
export type FirefoxRelaySettings = ApiSettings;
|
||||||
|
export type FirefoxRelayOptions = ApiOptions;
|
||||||
|
export type FirefoxRelayConfiguration = ForwarderConfiguration<FirefoxRelaySettings>;
|
||||||
|
|
||||||
|
// default values
|
||||||
|
const defaultSettings = Object.freeze({
|
||||||
|
token: "",
|
||||||
|
} as FirefoxRelaySettings);
|
||||||
|
|
||||||
|
// supported RPC calls
|
||||||
|
const createForwardingEmail = Object.freeze({
|
||||||
|
url(_request: IntegrationRequest, context: ForwarderContext<FirefoxRelaySettings>) {
|
||||||
|
return context.baseUrl() + "/v1/relayaddresses/";
|
||||||
|
},
|
||||||
|
body(request: IntegrationRequest, context: ForwarderContext<FirefoxRelaySettings>) {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
generated_for: context.website(request),
|
||||||
|
description: context.generatedBy(request),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hasJsonPayload(response: Response) {
|
||||||
|
return response.status === 200 || response.status === 201;
|
||||||
|
},
|
||||||
|
processJson(json: any) {
|
||||||
|
return [json.full_address];
|
||||||
|
},
|
||||||
|
} as CreateForwardingEmailRpcDef<FirefoxRelaySettings>);
|
||||||
|
|
||||||
|
// forwarder configuration
|
||||||
|
const forwarder = Object.freeze({
|
||||||
|
defaultSettings,
|
||||||
|
settings: new UserKeyDefinition<FirefoxRelaySettings>(GENERATOR_DISK, "firefoxRelayForwarder", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: [],
|
||||||
|
}),
|
||||||
|
importBuffer: new BufferedKeyDefinition<FirefoxRelaySettings>(
|
||||||
|
GENERATOR_DISK,
|
||||||
|
"firefoxRelayBuffer",
|
||||||
|
{
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
createForwardingEmail,
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
// integration-wide configuration
|
||||||
|
export const FirefoxRelay = Object.freeze({
|
||||||
|
id: "firefoxrelay" as IntegrationId,
|
||||||
|
name: "Firefox Relay",
|
||||||
|
baseUrl: "https://relay.firefox.com/api",
|
||||||
|
selfHost: "never",
|
||||||
|
extends: ["forwarder"],
|
||||||
|
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
|
||||||
|
return { Authorization: "Token " + context.authenticationToken() };
|
||||||
|
},
|
||||||
|
forwarder,
|
||||||
|
} as FirefoxRelayConfiguration);
|
@ -0,0 +1,96 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { ForwarderContext } from "../engine";
|
||||||
|
|
||||||
|
import { ForwardEmail, ForwardEmailSettings } from "./forward-email";
|
||||||
|
|
||||||
|
describe("Addy.io forwarder", () => {
|
||||||
|
const context = mock<ForwarderContext<ForwardEmailSettings>>();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authenticate", () => {
|
||||||
|
it("returns a bearer header with the token", () => {
|
||||||
|
context.authenticationToken.mockReturnValue("token");
|
||||||
|
|
||||||
|
const result = ForwardEmail.authenticate(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual({ Authorization: "Basic token" });
|
||||||
|
expect(context.authenticationToken).toHaveBeenCalledWith({ base64: true, suffix: ":" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = ForwardEmail.forwarder.settings.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("importBuffer", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = ForwardEmail.forwarder.importBuffer.options.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createForwardingEmail", () => {
|
||||||
|
describe("url", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.baseUrl.mockReturnValue("");
|
||||||
|
context.emailDomain.mockReturnValue("email domain");
|
||||||
|
|
||||||
|
const result = ForwardEmail.forwarder.createForwardingEmail.url(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual("/v1/domains/email domain/aliases");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("body", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.website.mockReturnValue("website");
|
||||||
|
context.generatedBy.mockReturnValue("generated by");
|
||||||
|
|
||||||
|
const result = ForwardEmail.forwarder.createForwardingEmail.body(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
labels: "website",
|
||||||
|
description: "generated by",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasJsonPayload", () => {
|
||||||
|
it.each([[200], [201]])("returns true when the status is $%i", (status) => {
|
||||||
|
const result = ForwardEmail.forwarder.createForwardingEmail.hasJsonPayload(
|
||||||
|
{ status } as Response,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processJson", () => {
|
||||||
|
it("should read the email from the response", () => {
|
||||||
|
const json = { name: "foo", domain: { name: "example.com" } };
|
||||||
|
|
||||||
|
const result = ForwardEmail.forwarder.createForwardingEmail.processJson(json, context);
|
||||||
|
|
||||||
|
expect(result).toEqual(["foo@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use the domain from the request when it is not specified", () => {
|
||||||
|
context.emailDomain.mockReturnValue("example.com");
|
||||||
|
const json = { name: "foo" };
|
||||||
|
|
||||||
|
const result = ForwardEmail.forwarder.createForwardingEmail.processJson(json, context);
|
||||||
|
|
||||||
|
expect(result).toEqual(["foo@example.com"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
76
libs/tools/generator/core/src/integration/forward-email.ts
Normal file
76
libs/tools/generator/core/src/integration/forward-email.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
|
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
|
||||||
|
import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine";
|
||||||
|
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
||||||
|
import { ApiOptions, EmailDomainOptions } from "../types";
|
||||||
|
|
||||||
|
// integration types
|
||||||
|
export type ForwardEmailSettings = ApiSettings & EmailDomainSettings;
|
||||||
|
export type ForwardEmailOptions = ApiOptions & EmailDomainOptions;
|
||||||
|
export type ForwardEmailConfiguration = ForwarderConfiguration<ForwardEmailSettings>;
|
||||||
|
|
||||||
|
// default values
|
||||||
|
const defaultSettings = Object.freeze({
|
||||||
|
token: "",
|
||||||
|
domain: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// supported RPC calls
|
||||||
|
const createForwardingEmail = Object.freeze({
|
||||||
|
url(_request: IntegrationRequest, context: ForwarderContext<ForwardEmailSettings>) {
|
||||||
|
const domain = context.emailDomain();
|
||||||
|
return context.baseUrl() + `/v1/domains/${domain}/aliases`;
|
||||||
|
},
|
||||||
|
body(request: IntegrationRequest, context: ForwarderContext<ForwardEmailSettings>) {
|
||||||
|
return {
|
||||||
|
labels: context.website(request),
|
||||||
|
description: context.generatedBy(request),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hasJsonPayload(response: Response) {
|
||||||
|
return response.status === 200 || response.status === 201;
|
||||||
|
},
|
||||||
|
processJson(json: any, context: ForwarderContext<ForwardEmailSettings>) {
|
||||||
|
const { name, domain } = json;
|
||||||
|
const domainPart = domain?.name ?? context.emailDomain();
|
||||||
|
return [`${name}@${domainPart}`];
|
||||||
|
},
|
||||||
|
} as CreateForwardingEmailRpcDef<ForwardEmailSettings>);
|
||||||
|
|
||||||
|
// forwarder configuration
|
||||||
|
const forwarder = Object.freeze({
|
||||||
|
defaultSettings,
|
||||||
|
settings: new UserKeyDefinition<ForwardEmailSettings>(GENERATOR_DISK, "forwardEmailForwarder", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: [],
|
||||||
|
}),
|
||||||
|
importBuffer: new BufferedKeyDefinition<ForwardEmailSettings>(
|
||||||
|
GENERATOR_DISK,
|
||||||
|
"forwardEmailBuffer",
|
||||||
|
{
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
createForwardingEmail,
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
export const ForwardEmail = Object.freeze({
|
||||||
|
// integration metadata
|
||||||
|
id: "forwardemail" as IntegrationId,
|
||||||
|
name: "Forward Email",
|
||||||
|
extends: ["forwarder"],
|
||||||
|
|
||||||
|
// service provider
|
||||||
|
selfHost: "never",
|
||||||
|
baseUrl: "https://api.forwardemail.net",
|
||||||
|
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
|
||||||
|
return { Authorization: "Basic " + context.authenticationToken({ base64: true, suffix: ":" }) };
|
||||||
|
},
|
||||||
|
|
||||||
|
// specialized configurations
|
||||||
|
forwarder,
|
||||||
|
} as ForwardEmailConfiguration);
|
6
libs/tools/generator/core/src/integration/index.ts
Normal file
6
libs/tools/generator/core/src/integration/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./addy-io";
|
||||||
|
export * from "./duck-duck-go";
|
||||||
|
export * from "./fastmail";
|
||||||
|
export * from "./firefox-relay";
|
||||||
|
export * from "./forward-email";
|
||||||
|
export * from "./simple-login";
|
@ -0,0 +1,92 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { ForwarderContext } from "../engine";
|
||||||
|
|
||||||
|
import { SimpleLogin, SimpleLoginSettings } from "./simple-login";
|
||||||
|
|
||||||
|
describe("Addy.io forwarder", () => {
|
||||||
|
const context = mock<ForwarderContext<SimpleLoginSettings>>();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authenticate", () => {
|
||||||
|
it("returns a bearer header with the token", () => {
|
||||||
|
context.authenticationToken.mockReturnValue("token");
|
||||||
|
|
||||||
|
const result = SimpleLogin.authenticate(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual({ Authentication: "token" });
|
||||||
|
expect(context.authenticationToken).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = SimpleLogin.forwarder.settings.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("importBuffer", () => {
|
||||||
|
it("should pass through deserialization", () => {
|
||||||
|
const value: any = {};
|
||||||
|
const result = SimpleLogin.forwarder.importBuffer.options.deserializer(value);
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createForwardingEmail", () => {
|
||||||
|
describe("url", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.website.mockReturnValue("");
|
||||||
|
context.baseUrl.mockReturnValue("");
|
||||||
|
|
||||||
|
const result = SimpleLogin.forwarder.createForwardingEmail.url(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual("/api/alias/random/new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the website in the alias path", () => {
|
||||||
|
context.baseUrl.mockReturnValue("");
|
||||||
|
context.website.mockReturnValue("website");
|
||||||
|
|
||||||
|
const result = SimpleLogin.forwarder.createForwardingEmail.url(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual("/api/alias/random/new?hostname=website");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("body", () => {
|
||||||
|
it("returns the alias path", () => {
|
||||||
|
context.generatedBy.mockReturnValue("generated by");
|
||||||
|
|
||||||
|
const result = SimpleLogin.forwarder.createForwardingEmail.body(null, context);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
note: "generated by",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasJsonPayload", () => {
|
||||||
|
it.each([[200], [201]])("returns true when the status is $%i", (status) => {
|
||||||
|
const result = SimpleLogin.forwarder.createForwardingEmail.hasJsonPayload(
|
||||||
|
{ status } as Response,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processJson", () => {
|
||||||
|
it("should read the email from the response", () => {
|
||||||
|
const json = { alias: "foo@example.com" };
|
||||||
|
const result = SimpleLogin.forwarder.createForwardingEmail.processJson(json, context);
|
||||||
|
expect(result).toEqual(["foo@example.com"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
74
libs/tools/generator/core/src/integration/simple-login.ts
Normal file
74
libs/tools/generator/core/src/integration/simple-login.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
|
import {
|
||||||
|
ApiSettings,
|
||||||
|
IntegrationRequest,
|
||||||
|
SelfHostedApiSettings,
|
||||||
|
} from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
|
||||||
|
import { ForwarderConfiguration, ForwarderContext } from "../engine";
|
||||||
|
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
||||||
|
import { SelfHostedApiOptions } from "../types";
|
||||||
|
|
||||||
|
// integration types
|
||||||
|
export type SimpleLoginSettings = SelfHostedApiSettings;
|
||||||
|
export type SimpleLoginOptions = SelfHostedApiOptions;
|
||||||
|
export type SimpleLoginConfiguration = ForwarderConfiguration<SimpleLoginSettings>;
|
||||||
|
|
||||||
|
// default values
|
||||||
|
const defaultSettings = Object.freeze({
|
||||||
|
token: "",
|
||||||
|
domain: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// supported RPC calls
|
||||||
|
const createForwardingEmail = Object.freeze({
|
||||||
|
url(request: IntegrationRequest, context: ForwarderContext<SimpleLoginSettings>) {
|
||||||
|
const endpoint = context.baseUrl() + "/api/alias/random/new";
|
||||||
|
const hostname = context.website(request);
|
||||||
|
const url = hostname !== "" ? `${endpoint}?hostname=${hostname}` : endpoint;
|
||||||
|
|
||||||
|
return url;
|
||||||
|
},
|
||||||
|
body(request: IntegrationRequest, context: ForwarderContext<SimpleLoginSettings>) {
|
||||||
|
return { note: context.generatedBy(request) };
|
||||||
|
},
|
||||||
|
hasJsonPayload(response: Response) {
|
||||||
|
return response.status === 200 || response.status === 201;
|
||||||
|
},
|
||||||
|
processJson(json: any) {
|
||||||
|
return [json?.alias];
|
||||||
|
},
|
||||||
|
} as CreateForwardingEmailRpcDef<SimpleLoginSettings>);
|
||||||
|
|
||||||
|
// forwarder configuration
|
||||||
|
const forwarder = Object.freeze({
|
||||||
|
defaultSettings,
|
||||||
|
settings: new UserKeyDefinition<SimpleLoginSettings>(GENERATOR_DISK, "simpleLoginForwarder", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: [],
|
||||||
|
}),
|
||||||
|
importBuffer: new BufferedKeyDefinition<SimpleLoginSettings>(
|
||||||
|
GENERATOR_DISK,
|
||||||
|
"simpleLoginBuffer",
|
||||||
|
{
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
createForwardingEmail,
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
// integration-wide configuration
|
||||||
|
export const SimpleLogin = Object.freeze({
|
||||||
|
id: "simplelogin" as IntegrationId,
|
||||||
|
name: "SimpleLogin",
|
||||||
|
selfHost: "maybe",
|
||||||
|
extends: ["forwarder"],
|
||||||
|
baseUrl: "https://app.simplelogin.io",
|
||||||
|
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
|
||||||
|
return { Authentication: context.authenticationToken() };
|
||||||
|
},
|
||||||
|
forwarder,
|
||||||
|
} as SimpleLoginConfiguration);
|
@ -6,7 +6,7 @@ import { DefaultCatchallOptions } from "../data";
|
|||||||
import { EmailCalculator, EmailRandomizer } from "../engine";
|
import { EmailCalculator, EmailRandomizer } from "../engine";
|
||||||
import { newDefaultEvaluator } from "../rx";
|
import { newDefaultEvaluator } from "../rx";
|
||||||
import { NoPolicy, CatchallGenerationOptions } from "../types";
|
import { NoPolicy, CatchallGenerationOptions } from "../types";
|
||||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
import { observe$PerUserId, sharedStateByUserId } from "../util";
|
||||||
|
|
||||||
import { CATCHALL_SETTINGS } from "./storage";
|
import { CATCHALL_SETTINGS } from "./storage";
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ export class CatchallGeneratorStrategy
|
|||||||
|
|
||||||
// configuration
|
// configuration
|
||||||
durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider);
|
durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider);
|
||||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
defaults$ = observe$PerUserId(() => this.defaultOptions);
|
||||||
toEvaluator = newDefaultEvaluator<CatchallGenerationOptions>();
|
toEvaluator = newDefaultEvaluator<CatchallGenerationOptions>();
|
||||||
readonly policy = PolicyType.PasswordGenerator;
|
readonly policy = PolicyType.PasswordGenerator;
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { DefaultEffUsernameOptions, UsernameDigits } from "../data";
|
|||||||
import { UsernameRandomizer } from "../engine";
|
import { UsernameRandomizer } from "../engine";
|
||||||
import { newDefaultEvaluator } from "../rx";
|
import { newDefaultEvaluator } from "../rx";
|
||||||
import { EffUsernameGenerationOptions, NoPolicy } from "../types";
|
import { EffUsernameGenerationOptions, NoPolicy } from "../types";
|
||||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
import { observe$PerUserId, sharedStateByUserId } from "../util";
|
||||||
|
|
||||||
import { EFF_USERNAME_SETTINGS } from "./storage";
|
import { EFF_USERNAME_SETTINGS } from "./storage";
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ export class EffUsernameGeneratorStrategy
|
|||||||
|
|
||||||
// configuration
|
// configuration
|
||||||
durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider);
|
durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider);
|
||||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
defaults$ = observe$PerUserId(() => this.defaultOptions);
|
||||||
toEvaluator = newDefaultEvaluator<EffUsernameGenerationOptions>();
|
toEvaluator = newDefaultEvaluator<EffUsernameGenerationOptions>();
|
||||||
readonly policy = PolicyType.PasswordGenerator;
|
readonly policy = PolicyType.PasswordGenerator;
|
||||||
|
|
||||||
|
@ -7,41 +7,17 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
|||||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { RestClient } from "@bitwarden/common/tools/integration/rpc";
|
||||||
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
|
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { UserKey } from "@bitwarden/common/types/key";
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
|
|
||||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../common/spec";
|
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../common/spec";
|
||||||
import { DefaultDuckDuckGoOptions } from "../data";
|
import { AddyIo, Fastmail, FirefoxRelay } from "../integration";
|
||||||
import { DefaultPolicyEvaluator } from "../policies";
|
import { DefaultPolicyEvaluator } from "../policies";
|
||||||
import { ApiOptions } from "../types";
|
|
||||||
|
|
||||||
import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy";
|
import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy";
|
||||||
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "./storage";
|
|
||||||
|
|
||||||
class TestForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
|
|
||||||
constructor(
|
|
||||||
encryptService: EncryptService,
|
|
||||||
keyService: CryptoService,
|
|
||||||
stateProvider: StateProvider,
|
|
||||||
) {
|
|
||||||
super(encryptService, keyService, stateProvider, { website: null, token: "" });
|
|
||||||
}
|
|
||||||
|
|
||||||
get key() {
|
|
||||||
// arbitrary.
|
|
||||||
return DUCK_DUCK_GO_FORWARDER;
|
|
||||||
}
|
|
||||||
|
|
||||||
get rolloverKey() {
|
|
||||||
return DUCK_DUCK_GO_BUFFER;
|
|
||||||
}
|
|
||||||
|
|
||||||
defaults$ = (userId: UserId) => {
|
|
||||||
return of(DefaultDuckDuckGoOptions);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const SomeUser = "some user" as UserId;
|
const SomeUser = "some user" as UserId;
|
||||||
const AnotherUser = "another user" as UserId;
|
const AnotherUser = "another user" as UserId;
|
||||||
@ -56,6 +32,8 @@ describe("ForwarderGeneratorStrategy", () => {
|
|||||||
const encryptService = mock<EncryptService>();
|
const encryptService = mock<EncryptService>();
|
||||||
const keyService = mock<CryptoService>();
|
const keyService = mock<CryptoService>();
|
||||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||||
|
const restClient = mock<RestClient>();
|
||||||
|
const i18nService = mock<I18nService>();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const keyAvailable = of({} as UserKey);
|
const keyAvailable = of({} as UserKey);
|
||||||
@ -68,7 +46,14 @@ describe("ForwarderGeneratorStrategy", () => {
|
|||||||
|
|
||||||
describe("durableState", () => {
|
describe("durableState", () => {
|
||||||
it("constructs a secret state", () => {
|
it("constructs a secret state", () => {
|
||||||
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
|
const strategy = new ForwarderGeneratorStrategy(
|
||||||
|
AddyIo,
|
||||||
|
restClient,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
keyService,
|
||||||
|
stateProvider,
|
||||||
|
);
|
||||||
|
|
||||||
const result = strategy.durableState(SomeUser);
|
const result = strategy.durableState(SomeUser);
|
||||||
|
|
||||||
@ -76,7 +61,14 @@ describe("ForwarderGeneratorStrategy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns the same secret state for a single user", () => {
|
it("returns the same secret state for a single user", () => {
|
||||||
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
|
const strategy = new ForwarderGeneratorStrategy(
|
||||||
|
AddyIo,
|
||||||
|
restClient,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
keyService,
|
||||||
|
stateProvider,
|
||||||
|
);
|
||||||
|
|
||||||
const firstResult = strategy.durableState(SomeUser);
|
const firstResult = strategy.durableState(SomeUser);
|
||||||
const secondResult = strategy.durableState(SomeUser);
|
const secondResult = strategy.durableState(SomeUser);
|
||||||
@ -85,7 +77,14 @@ describe("ForwarderGeneratorStrategy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns a different secret state for a different user", () => {
|
it("returns a different secret state for a different user", () => {
|
||||||
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
|
const strategy = new ForwarderGeneratorStrategy(
|
||||||
|
AddyIo,
|
||||||
|
restClient,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
keyService,
|
||||||
|
stateProvider,
|
||||||
|
);
|
||||||
|
|
||||||
const firstResult = strategy.durableState(SomeUser);
|
const firstResult = strategy.durableState(SomeUser);
|
||||||
const secondResult = strategy.durableState(AnotherUser);
|
const secondResult = strategy.durableState(AnotherUser);
|
||||||
@ -98,7 +97,14 @@ describe("ForwarderGeneratorStrategy", () => {
|
|||||||
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
|
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
|
||||||
"should map any input (= %p) to the default policy evaluator",
|
"should map any input (= %p) to the default policy evaluator",
|
||||||
async (policies) => {
|
async (policies) => {
|
||||||
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
|
const strategy = new ForwarderGeneratorStrategy(
|
||||||
|
AddyIo,
|
||||||
|
restClient,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
keyService,
|
||||||
|
stateProvider,
|
||||||
|
);
|
||||||
|
|
||||||
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
|
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
|
||||||
const evaluator = await firstValueFrom(evaluator$);
|
const evaluator = await firstValueFrom(evaluator$);
|
||||||
@ -107,4 +113,39 @@ describe("ForwarderGeneratorStrategy", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("generate", () => {
|
||||||
|
it("issues a remote procedure request to create the forwarding address", async () => {
|
||||||
|
restClient.fetchJson.mockResolvedValue("jdoe@example.com");
|
||||||
|
const strategy = new ForwarderGeneratorStrategy(
|
||||||
|
FirefoxRelay,
|
||||||
|
restClient,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
keyService,
|
||||||
|
stateProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await strategy.generate({ website: null });
|
||||||
|
|
||||||
|
expect(result).toEqual("jdoe@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues a remote procedure request to look up the account id before creating the forwarding address", async () => {
|
||||||
|
restClient.fetchJson.mockResolvedValue("some account id");
|
||||||
|
restClient.fetchJson.mockResolvedValue("jdoe@example.com");
|
||||||
|
const strategy = new ForwarderGeneratorStrategy(
|
||||||
|
Fastmail,
|
||||||
|
restClient,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
keyService,
|
||||||
|
stateProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await strategy.generate({ website: null, prefix: "", domain: "example.com" });
|
||||||
|
|
||||||
|
expect(result).toEqual("jdoe@example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,32 +1,39 @@
|
|||||||
import { map } from "rxjs";
|
import { map } from "rxjs";
|
||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||||
import {
|
import {
|
||||||
SingleUserState,
|
ApiSettings,
|
||||||
StateProvider,
|
IntegrationRequest,
|
||||||
UserKeyDefinition,
|
RestClient,
|
||||||
} from "@bitwarden/common/platform/state";
|
} from "@bitwarden/common/tools/integration/rpc";
|
||||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
|
||||||
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
|
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
|
||||||
import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer";
|
import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer";
|
||||||
import { SecretClassifier } from "@bitwarden/common/tools/state/secret-classifier";
|
|
||||||
import { SecretKeyDefinition } from "@bitwarden/common/tools/state/secret-key-definition";
|
import { SecretKeyDefinition } from "@bitwarden/common/tools/state/secret-key-definition";
|
||||||
import { SecretState } from "@bitwarden/common/tools/state/secret-state";
|
import { SecretState } from "@bitwarden/common/tools/state/secret-state";
|
||||||
import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor";
|
import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { GeneratorStrategy } from "../abstractions";
|
import { GeneratorStrategy } from "../abstractions";
|
||||||
|
import { ForwarderConfiguration, AccountRequest, ForwarderContext } from "../engine";
|
||||||
|
import { CreateForwardingAddressRpc } from "../engine/rpc/create-forwarding-address";
|
||||||
|
import { GetAccountIdRpc } from "../engine/rpc/get-account-id";
|
||||||
import { newDefaultEvaluator } from "../rx";
|
import { newDefaultEvaluator } from "../rx";
|
||||||
import { ApiOptions, NoPolicy } from "../types";
|
import { NoPolicy } from "../types";
|
||||||
import { clone$PerUserId, sharedByUserId } from "../util";
|
import { observe$PerUserId, sharedByUserId } from "../util";
|
||||||
|
|
||||||
|
import { OptionsClassifier } from "./options-classifier";
|
||||||
|
|
||||||
const OPTIONS_FRAME_SIZE = 512;
|
const OPTIONS_FRAME_SIZE = 512;
|
||||||
|
|
||||||
/** An email forwarding service configurable through an API. */
|
/** An email forwarding service configurable through an API. */
|
||||||
export abstract class ForwarderGeneratorStrategy<
|
export class ForwarderGeneratorStrategy<
|
||||||
Options extends ApiOptions,
|
Settings extends ApiSettings,
|
||||||
|
Options extends Settings & IntegrationRequest = Settings & IntegrationRequest,
|
||||||
> extends GeneratorStrategy<Options, NoPolicy> {
|
> extends GeneratorStrategy<Options, NoPolicy> {
|
||||||
/** Initializes the generator strategy
|
/** Initializes the generator strategy
|
||||||
* @param encryptService protects sensitive forwarder options
|
* @param encryptService protects sensitive forwarder options
|
||||||
@ -34,26 +41,45 @@ export abstract class ForwarderGeneratorStrategy<
|
|||||||
* @param stateProvider creates the durable state for options storage
|
* @param stateProvider creates the durable state for options storage
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly configuration: ForwarderConfiguration<Settings>,
|
||||||
|
private client: RestClient,
|
||||||
|
private i18nService: I18nService,
|
||||||
private readonly encryptService: EncryptService,
|
private readonly encryptService: EncryptService,
|
||||||
private readonly keyService: CryptoService,
|
private readonly keyService: CryptoService,
|
||||||
private stateProvider: StateProvider,
|
private stateProvider: StateProvider,
|
||||||
private readonly defaultOptions: Options,
|
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** configures forwarder secret storage */
|
|
||||||
protected abstract readonly key: UserKeyDefinition<Options>;
|
|
||||||
|
|
||||||
/** configures forwarder import buffer */
|
|
||||||
protected abstract readonly rolloverKey: BufferedKeyDefinition<Options, Options>;
|
|
||||||
|
|
||||||
// configuration
|
// configuration
|
||||||
readonly policy = PolicyType.PasswordGenerator;
|
readonly policy = PolicyType.PasswordGenerator;
|
||||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
defaults$ = observe$PerUserId<Options>(
|
||||||
|
() => this.configuration.forwarder.defaultSettings as Options,
|
||||||
|
);
|
||||||
toEvaluator = newDefaultEvaluator<Options>();
|
toEvaluator = newDefaultEvaluator<Options>();
|
||||||
durableState = sharedByUserId((userId) => this.getUserSecrets(userId));
|
durableState = sharedByUserId((userId) => this.getUserSecrets(userId));
|
||||||
|
|
||||||
|
private get key() {
|
||||||
|
return this.configuration.forwarder.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get rolloverKey() {
|
||||||
|
return this.configuration.forwarder.importBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate = async (options: Options) => {
|
||||||
|
const requestOptions: IntegrationRequest & AccountRequest = { website: options.website };
|
||||||
|
|
||||||
|
const getAccount = await this.getAccountId(this.configuration, options);
|
||||||
|
if (getAccount) {
|
||||||
|
requestOptions.accountId = await this.client.fetchJson(getAccount, requestOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = this.createForwardingAddress(this.configuration, options);
|
||||||
|
const result = await this.client.fetchJson(create, requestOptions);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
// per-user encrypted state
|
// per-user encrypted state
|
||||||
private getUserSecrets(userId: UserId): SingleUserState<Options> {
|
private getUserSecrets(userId: UserId): SingleUserState<Options> {
|
||||||
// construct the encryptor
|
// construct the encryptor
|
||||||
@ -61,23 +87,27 @@ export abstract class ForwarderGeneratorStrategy<
|
|||||||
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
|
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
|
||||||
|
|
||||||
// always exclude request properties
|
// always exclude request properties
|
||||||
const classifier = SecretClassifier.allSecret<Options>().exclude("website");
|
const classifier = new OptionsClassifier<Settings, Options>();
|
||||||
|
|
||||||
// Derive the secret key definition
|
// Derive the secret key definition
|
||||||
const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, {
|
const key = SecretKeyDefinition.value<Options, Record<string, never>, Settings>(
|
||||||
deserializer: (d) => this.key.deserializer(d),
|
this.key.stateDefinition,
|
||||||
cleanupDelayMs: this.key.cleanupDelayMs,
|
this.key.key,
|
||||||
clearOn: this.key.clearOn,
|
classifier,
|
||||||
});
|
{
|
||||||
|
deserializer: (d: Jsonify<Options>) => this.key.deserializer(d as any) as any,
|
||||||
|
cleanupDelayMs: this.key.cleanupDelayMs,
|
||||||
|
clearOn: this.key.clearOn,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// the type parameter is explicit because type inference fails for `Omit<Options, "website">`
|
// the type parameter is explicit because type inference fails for `Omit<Options, "website">`
|
||||||
const secretState = SecretState.from<
|
const secretState = SecretState.from<Options, void, Options, Record<string, never>, Settings>(
|
||||||
Options,
|
userId,
|
||||||
void,
|
key,
|
||||||
Options,
|
this.stateProvider,
|
||||||
Record<keyof Options, never>,
|
encryptor,
|
||||||
Omit<Options, "website">
|
);
|
||||||
>(userId, key, this.stateProvider, encryptor);
|
|
||||||
|
|
||||||
// rollover should occur once the user key is available for decryption
|
// rollover should occur once the user key is available for decryption
|
||||||
const canDecrypt$ = this.keyService
|
const canDecrypt$ = this.keyService
|
||||||
@ -90,6 +120,39 @@ export abstract class ForwarderGeneratorStrategy<
|
|||||||
canDecrypt$,
|
canDecrypt$,
|
||||||
);
|
);
|
||||||
|
|
||||||
return rolloverState;
|
// cast through unknown required because there's no way to prove to
|
||||||
|
// the compiler that `OptionsClassifier` runs within the buffer wrapping
|
||||||
|
// the secret state.
|
||||||
|
return rolloverState as unknown as SingleUserState<Options>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createContext<Settings>(
|
||||||
|
configuration: ForwarderConfiguration<Settings>,
|
||||||
|
settings: Settings,
|
||||||
|
) {
|
||||||
|
return new ForwarderContext(configuration, settings, this.i18nService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createForwardingAddress<Settings extends ApiSettings>(
|
||||||
|
configuration: ForwarderConfiguration<Settings>,
|
||||||
|
settings: Settings,
|
||||||
|
) {
|
||||||
|
const context = this.createContext(configuration, settings);
|
||||||
|
const rpc = new CreateForwardingAddressRpc<Settings>(configuration, context);
|
||||||
|
return rpc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAccountId<Settings extends ApiSettings>(
|
||||||
|
configuration: ForwarderConfiguration<Settings>,
|
||||||
|
settings: Settings,
|
||||||
|
) {
|
||||||
|
if (!configuration.forwarder.getAccountId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = this.createContext(configuration, settings);
|
||||||
|
const rpc = new GetAccountIdRpc<Settings>(configuration, context);
|
||||||
|
|
||||||
|
return rpc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,233 +0,0 @@
|
|||||||
import { firstValueFrom } from "rxjs";
|
|
||||||
|
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultAddyIoOptions } from "../../data";
|
|
||||||
import { ADDY_IO_FORWARDER } from "../storage";
|
|
||||||
|
|
||||||
import { AddyIoForwarder } from "./addy-io";
|
|
||||||
import { mockApiService, mockI18nService } from "./mocks.jest";
|
|
||||||
|
|
||||||
const SomeUser = "some user" as UserId;
|
|
||||||
|
|
||||||
describe("Addy.io Forwarder", () => {
|
|
||||||
it("key returns the Addy IO forwarder key", () => {
|
|
||||||
const forwarder = new AddyIoForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
expect(forwarder.key).toBe(ADDY_IO_FORWARDER);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("defaults$", () => {
|
|
||||||
it("should return the default subaddress options", async () => {
|
|
||||||
const strategy = new AddyIoForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
|
||||||
|
|
||||||
expect(result).toEqual(DefaultAddyIoOptions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
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, null, null, null);
|
|
||||||
|
|
||||||
const result = await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,100 +0,0 @@
|
|||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
|
||||||
|
|
||||||
import { DefaultAddyIoOptions, Forwarders } from "../../data";
|
|
||||||
import { EmailDomainOptions, SelfHostedApiOptions } from "../../types";
|
|
||||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
|
||||||
import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../storage";
|
|
||||||
|
|
||||||
/** Generates a forwarding address for addy.io (formerly anon addy) */
|
|
||||||
export class AddyIoForwarder extends ForwarderGeneratorStrategy<
|
|
||||||
SelfHostedApiOptions & EmailDomainOptions
|
|
||||||
> {
|
|
||||||
/** Instantiates the forwarder
|
|
||||||
* @param apiService used for ajax requests to the forwarding service
|
|
||||||
* @param i18nService used to look up error strings
|
|
||||||
* @param encryptService protects sensitive forwarder options
|
|
||||||
* @param keyService looks up the user key when protecting data.
|
|
||||||
* @param stateProvider creates the durable state for options storage
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
private apiService: ApiService,
|
|
||||||
private i18nService: I18nService,
|
|
||||||
encryptService: EncryptService,
|
|
||||||
keyService: CryptoService,
|
|
||||||
stateProvider: StateProvider,
|
|
||||||
) {
|
|
||||||
super(encryptService, keyService, stateProvider, DefaultAddyIoOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// configuration
|
|
||||||
readonly key = ADDY_IO_FORWARDER;
|
|
||||||
readonly rolloverKey = ADDY_IO_BUFFER;
|
|
||||||
|
|
||||||
// request
|
|
||||||
generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let descriptionId = "forwarderGeneratedByWithWebsite";
|
|
||||||
if (!options.website || options.website === "") {
|
|
||||||
descriptionId = "forwarderGeneratedBy";
|
|
||||||
}
|
|
||||||
const description = this.i18nService.t(descriptionId, options.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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DefaultOptions = Object.freeze({
|
|
||||||
website: null,
|
|
||||||
baseUrl: "https://app.addy.io",
|
|
||||||
domain: "",
|
|
||||||
token: "",
|
|
||||||
});
|
|
@ -1,144 +0,0 @@
|
|||||||
import { firstValueFrom } from "rxjs";
|
|
||||||
|
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultDuckDuckGoOptions } from "../../data";
|
|
||||||
import { DUCK_DUCK_GO_FORWARDER } from "../storage";
|
|
||||||
|
|
||||||
import { DuckDuckGoForwarder } from "./duck-duck-go";
|
|
||||||
import { mockApiService, mockI18nService } from "./mocks.jest";
|
|
||||||
|
|
||||||
const SomeUser = "some user" as UserId;
|
|
||||||
|
|
||||||
describe("DuckDuckGo Forwarder", () => {
|
|
||||||
it("key returns the Duck Duck Go forwarder key", () => {
|
|
||||||
const forwarder = new DuckDuckGoForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
expect(forwarder.key).toBe(DUCK_DUCK_GO_FORWARDER);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("defaults$", () => {
|
|
||||||
it("should return the default subaddress options", async () => {
|
|
||||||
const strategy = new DuckDuckGoForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
|
||||||
|
|
||||||
expect(result).toEqual(DefaultDuckDuckGoOptions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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 DuckDuckGoForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token,
|
|
||||||
}),
|
|
||||||
).rejects.toEqual("forwaderInvalidToken");
|
|
||||||
|
|
||||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
|
||||||
expect(i18nService.t).toHaveBeenCalledWith(
|
|
||||||
"forwaderInvalidToken",
|
|
||||||
Forwarders.DuckDuckGo.name,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["jane.doe@duck.com", 201, "jane.doe"],
|
|
||||||
["john.doe@duck.com", 201, "john.doe"],
|
|
||||||
["jane.doe@duck.com", 200, "jane.doe"],
|
|
||||||
["john.doe@duck.com", 200, "john.doe"],
|
|
||||||
])(
|
|
||||||
"returns the generated email address (= %p) if the request is successful (status = %p)",
|
|
||||||
async (email, status, address) => {
|
|
||||||
const apiService = mockApiService(status, { address });
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
const result = await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
});
|
|
||||||
|
|
||||||
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 DuckDuckGoForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
}),
|
|
||||||
).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.DuckDuckGo.name,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws an unknown error if the request is successful but an address isn't present", async () => {
|
|
||||||
const apiService = mockApiService(200, {});
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
}),
|
|
||||||
).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.DuckDuckGo.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(statusCode, {});
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
}),
|
|
||||||
).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.DuckDuckGo.name,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,75 +0,0 @@
|
|||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultDuckDuckGoOptions } from "../../data";
|
|
||||||
import { ApiOptions } from "../../types";
|
|
||||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
|
||||||
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../storage";
|
|
||||||
|
|
||||||
/** Generates a forwarding address for DuckDuckGo */
|
|
||||||
export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
|
|
||||||
/** Instantiates the forwarder
|
|
||||||
* @param apiService used for ajax requests to the forwarding service
|
|
||||||
* @param i18nService used to look up error strings
|
|
||||||
* @param encryptService protects sensitive forwarder options
|
|
||||||
* @param keyService looks up the user key when protecting data.
|
|
||||||
* @param stateProvider creates the durable state for options storage
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
private apiService: ApiService,
|
|
||||||
private i18nService: I18nService,
|
|
||||||
encryptService: EncryptService,
|
|
||||||
keyService: CryptoService,
|
|
||||||
stateProvider: StateProvider,
|
|
||||||
) {
|
|
||||||
super(encryptService, keyService, stateProvider, DefaultDuckDuckGoOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// configuration
|
|
||||||
readonly key = DUCK_DUCK_GO_FORWARDER;
|
|
||||||
readonly rolloverKey = DUCK_DUCK_GO_BUFFER;
|
|
||||||
|
|
||||||
// request
|
|
||||||
generate = async (options: ApiOptions): Promise<string> => {
|
|
||||||
if (!options.token || options.token === "") {
|
|
||||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = "https://quack.duckduckgo.com/api/email/addresses";
|
|
||||||
const request = new Request(url, {
|
|
||||||
redirect: "manual",
|
|
||||||
cache: "no-store",
|
|
||||||
method: "POST",
|
|
||||||
headers: new Headers({
|
|
||||||
Authorization: "Bearer " + options.token,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await this.apiService.nativeFetch(request);
|
|
||||||
if (response.status === 200 || response.status === 201) {
|
|
||||||
const json = await response.json();
|
|
||||||
if (json.address) {
|
|
||||||
return `${json.address}@duck.com`;
|
|
||||||
} else {
|
|
||||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} else if (response.status === 401) {
|
|
||||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name);
|
|
||||||
throw error;
|
|
||||||
} else {
|
|
||||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DefaultOptions = Object.freeze({
|
|
||||||
website: null,
|
|
||||||
token: "",
|
|
||||||
});
|
|
@ -1,281 +0,0 @@
|
|||||||
import { firstValueFrom } from "rxjs";
|
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultFastmailOptions } from "../../data";
|
|
||||||
import { FASTMAIL_FORWARDER } from "../storage";
|
|
||||||
|
|
||||||
import { FastmailForwarder } from "./fastmail";
|
|
||||||
import { mockI18nService } from "./mocks.jest";
|
|
||||||
|
|
||||||
const SomeUser = "some user" as UserId;
|
|
||||||
|
|
||||||
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", () => {
|
|
||||||
it("key returns the Fastmail forwarder key", () => {
|
|
||||||
const forwarder = new FastmailForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
expect(forwarder.key).toBe(FASTMAIL_FORWARDER);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("defaults$", () => {
|
|
||||||
it("should return the default subaddress options", async () => {
|
|
||||||
const strategy = new FastmailForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
|
||||||
|
|
||||||
expect(result).toEqual(DefaultFastmailOptions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
const result = await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: 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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,150 +0,0 @@
|
|||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultFastmailOptions } from "../../data";
|
|
||||||
import { EmailPrefixOptions, ApiOptions } from "../../types";
|
|
||||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
|
||||||
import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../storage";
|
|
||||||
|
|
||||||
/** Generates a forwarding address for Fastmail */
|
|
||||||
export class FastmailForwarder extends ForwarderGeneratorStrategy<ApiOptions & EmailPrefixOptions> {
|
|
||||||
/** Instantiates the forwarder
|
|
||||||
* @param apiService used for ajax requests to the forwarding service
|
|
||||||
* @param i18nService used to look up error strings
|
|
||||||
* @param encryptService protects sensitive forwarder options
|
|
||||||
* @param keyService looks up the user key when protecting data.
|
|
||||||
* @param stateProvider creates the durable state for options storage
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
private apiService: ApiService,
|
|
||||||
private i18nService: I18nService,
|
|
||||||
encryptService: EncryptService,
|
|
||||||
keyService: CryptoService,
|
|
||||||
stateProvider: StateProvider,
|
|
||||||
) {
|
|
||||||
super(encryptService, keyService, stateProvider, DefaultFastmailOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// configuration
|
|
||||||
readonly key = FASTMAIL_FORWARDER;
|
|
||||||
readonly rolloverKey = FASTMAIL_BUFFER;
|
|
||||||
|
|
||||||
// request
|
|
||||||
generate = async (options: ApiOptions & EmailPrefixOptions) => {
|
|
||||||
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: options.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DefaultOptions = Object.freeze({
|
|
||||||
website: null,
|
|
||||||
domain: "",
|
|
||||||
prefix: "",
|
|
||||||
token: "",
|
|
||||||
});
|
|
@ -1,147 +0,0 @@
|
|||||||
import { firstValueFrom } from "rxjs";
|
|
||||||
|
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultFirefoxRelayOptions } from "../../data";
|
|
||||||
import { FIREFOX_RELAY_FORWARDER } from "../storage";
|
|
||||||
|
|
||||||
import { FirefoxRelayForwarder } from "./firefox-relay";
|
|
||||||
import { mockApiService, mockI18nService } from "./mocks.jest";
|
|
||||||
|
|
||||||
const SomeUser = "some user" as UserId;
|
|
||||||
|
|
||||||
describe("Firefox Relay Forwarder", () => {
|
|
||||||
it("key returns the Firefox Relay forwarder key", () => {
|
|
||||||
const forwarder = new FirefoxRelayForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
expect(forwarder.key).toBe(FIREFOX_RELAY_FORWARDER);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("defaults$", () => {
|
|
||||||
it("should return the default subaddress options", async () => {
|
|
||||||
const strategy = new FirefoxRelayForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
|
||||||
|
|
||||||
expect(result).toEqual(DefaultFirefoxRelayOptions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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 FirefoxRelayForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token,
|
|
||||||
}),
|
|
||||||
).rejects.toEqual("forwaderInvalidToken");
|
|
||||||
|
|
||||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
|
||||||
expect(i18nService.t).toHaveBeenCalledWith(
|
|
||||||
"forwaderInvalidToken",
|
|
||||||
Forwarders.FirefoxRelay.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 FirefoxRelayForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await forwarder.generate({
|
|
||||||
website,
|
|
||||||
token: "token",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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@duck.com", 201],
|
|
||||||
["john.doe@duck.com", 201],
|
|
||||||
["jane.doe@duck.com", 200],
|
|
||||||
["john.doe@duck.com", 200],
|
|
||||||
])(
|
|
||||||
"returns the generated email address (= %p) if the request is successful (status = %p)",
|
|
||||||
async (full_address, status) => {
|
|
||||||
const apiService = mockApiService(status, { full_address });
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
const result = await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual(full_address);
|
|
||||||
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 FirefoxRelayForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
}),
|
|
||||||
).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.FirefoxRelay.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(statusCode, {});
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
}),
|
|
||||||
).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.FirefoxRelay.name,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,82 +0,0 @@
|
|||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultFirefoxRelayOptions } from "../../data";
|
|
||||||
import { ApiOptions } from "../../types";
|
|
||||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
|
||||||
import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../storage";
|
|
||||||
|
|
||||||
/** Generates a forwarding address for Firefox Relay */
|
|
||||||
export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
|
|
||||||
/** Instantiates the forwarder
|
|
||||||
* @param apiService used for ajax requests to the forwarding service
|
|
||||||
* @param i18nService used to look up error strings
|
|
||||||
* @param encryptService protects sensitive forwarder options
|
|
||||||
* @param keyService looks up the user key when protecting data.
|
|
||||||
* @param stateProvider creates the durable state for options storage
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
private apiService: ApiService,
|
|
||||||
private i18nService: I18nService,
|
|
||||||
encryptService: EncryptService,
|
|
||||||
keyService: CryptoService,
|
|
||||||
stateProvider: StateProvider,
|
|
||||||
) {
|
|
||||||
super(encryptService, keyService, stateProvider, DefaultFirefoxRelayOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// configuration
|
|
||||||
readonly key = FIREFOX_RELAY_FORWARDER;
|
|
||||||
readonly rolloverKey = FIREFOX_RELAY_BUFFER;
|
|
||||||
|
|
||||||
// request
|
|
||||||
generate = async (options: ApiOptions) => {
|
|
||||||
if (!options.token || options.token === "") {
|
|
||||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = "https://relay.firefox.com/api/v1/relayaddresses/";
|
|
||||||
|
|
||||||
let descriptionId = "forwarderGeneratedByWithWebsite";
|
|
||||||
if (!options.website || options.website === "") {
|
|
||||||
descriptionId = "forwarderGeneratedBy";
|
|
||||||
}
|
|
||||||
const description = this.i18nService.t(descriptionId, options.website ?? "");
|
|
||||||
|
|
||||||
const request = new Request(url, {
|
|
||||||
redirect: "manual",
|
|
||||||
cache: "no-store",
|
|
||||||
method: "POST",
|
|
||||||
headers: new Headers({
|
|
||||||
Authorization: "Token " + options.token,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}),
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: true,
|
|
||||||
generated_for: options.website,
|
|
||||||
description,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await this.apiService.nativeFetch(request);
|
|
||||||
if (response.status === 401) {
|
|
||||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name);
|
|
||||||
throw error;
|
|
||||||
} else if (response.status === 200 || response.status === 201) {
|
|
||||||
const json = await response.json();
|
|
||||||
return json.full_address;
|
|
||||||
} else {
|
|
||||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.FirefoxRelay.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DefaultOptions = Object.freeze({
|
|
||||||
website: null,
|
|
||||||
token: "",
|
|
||||||
});
|
|
@ -1,277 +0,0 @@
|
|||||||
import { firstValueFrom } from "rxjs";
|
|
||||||
|
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultForwardEmailOptions } from "../../data";
|
|
||||||
import { FORWARD_EMAIL_FORWARDER } from "../storage";
|
|
||||||
|
|
||||||
import { ForwardEmailForwarder } from "./forward-email";
|
|
||||||
import { mockApiService, mockI18nService } from "./mocks.jest";
|
|
||||||
|
|
||||||
const SomeUser = "some user" as UserId;
|
|
||||||
|
|
||||||
describe("ForwardEmail Forwarder", () => {
|
|
||||||
it("key returns the Forward Email forwarder key", () => {
|
|
||||||
const forwarder = new ForwardEmailForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
expect(forwarder.key).toBe(FORWARD_EMAIL_FORWARDER);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("defaults$", () => {
|
|
||||||
it("should return the default subaddress options", async () => {
|
|
||||||
const strategy = new ForwardEmailForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
|
||||||
|
|
||||||
expect(result).toEqual(DefaultForwardEmailOptions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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 ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token,
|
|
||||||
domain: "example.com",
|
|
||||||
}),
|
|
||||||
).rejects.toEqual("forwaderInvalidToken");
|
|
||||||
|
|
||||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
|
||||||
expect(i18nService.t).toHaveBeenCalledWith(
|
|
||||||
"forwaderInvalidToken",
|
|
||||||
Forwarders.ForwardEmail.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 ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
domain,
|
|
||||||
}),
|
|
||||||
).rejects.toEqual("forwarderNoDomain");
|
|
||||||
|
|
||||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
|
||||||
expect(i18nService.t).toHaveBeenCalledWith(
|
|
||||||
"forwarderNoDomain",
|
|
||||||
Forwarders.ForwardEmail.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 ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await forwarder.generate({
|
|
||||||
website,
|
|
||||||
token: "token",
|
|
||||||
domain: "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, { name: "jane.doe", domain: { name: "example.com" } }],
|
|
||||||
["jane.doe@example.com", 201, { name: "jane.doe" }],
|
|
||||||
["john.doe@example.com", 201, { name: "john.doe", domain: { name: "example.com" } }],
|
|
||||||
["john.doe@example.com", 201, { name: "john.doe" }],
|
|
||||||
["jane.doe@example.com", 200, { name: "jane.doe", domain: { name: "example.com" } }],
|
|
||||||
["jane.doe@example.com", 200, { name: "jane.doe" }],
|
|
||||||
["john.doe@example.com", 200, { name: "john.doe", domain: { name: "example.com" } }],
|
|
||||||
["john.doe@example.com", 200, { name: "john.doe" }],
|
|
||||||
])(
|
|
||||||
"returns the generated email address (= %p) if the request is successful (status = %p)",
|
|
||||||
async (email, status, response) => {
|
|
||||||
const apiService = mockApiService(status, response);
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
const result = await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
domain: "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 ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
domain: "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.ForwardEmail.name,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws an invalid token error with a message if the request fails with a 401 and message", async () => {
|
|
||||||
const apiService = mockApiService(401, { message: "A message" });
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
domain: "example.com",
|
|
||||||
}),
|
|
||||||
).rejects.toEqual("forwaderInvalidTokenWithMessage");
|
|
||||||
|
|
||||||
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,
|
|
||||||
"forwaderInvalidTokenWithMessage",
|
|
||||||
Forwarders.ForwardEmail.name,
|
|
||||||
"A message",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([{}, null])(
|
|
||||||
"throws an unknown error if the request fails and no status (= %p) is provided",
|
|
||||||
async (json) => {
|
|
||||||
const apiService = mockApiService(500, json);
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
domain: "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.ForwardEmail.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, message) => {
|
|
||||||
const apiService = mockApiService(statusCode, { message });
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
domain: "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.ForwardEmail.name,
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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, error) => {
|
|
||||||
const apiService = mockApiService(statusCode, { error });
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
domain: "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.ForwardEmail.name,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,104 +0,0 @@
|
|||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultForwardEmailOptions } from "../../data";
|
|
||||||
import { EmailDomainOptions, ApiOptions } from "../../types";
|
|
||||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
|
||||||
import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../storage";
|
|
||||||
|
|
||||||
/** Generates a forwarding address for Forward Email */
|
|
||||||
export class ForwardEmailForwarder extends ForwarderGeneratorStrategy<
|
|
||||||
ApiOptions & EmailDomainOptions
|
|
||||||
> {
|
|
||||||
/** Instantiates the forwarder
|
|
||||||
* @param apiService used for ajax requests to the forwarding service
|
|
||||||
* @param i18nService used to look up error strings
|
|
||||||
* @param encryptService protects sensitive forwarder options
|
|
||||||
* @param keyService looks up the user key when protecting data.
|
|
||||||
* @param stateProvider creates the durable state for options storage
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
private apiService: ApiService,
|
|
||||||
private i18nService: I18nService,
|
|
||||||
encryptService: EncryptService,
|
|
||||||
keyService: CryptoService,
|
|
||||||
stateProvider: StateProvider,
|
|
||||||
) {
|
|
||||||
super(encryptService, keyService, stateProvider, DefaultForwardEmailOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// configuration
|
|
||||||
readonly key = FORWARD_EMAIL_FORWARDER;
|
|
||||||
readonly rolloverKey = FORWARD_EMAIL_BUFFER;
|
|
||||||
|
|
||||||
// request
|
|
||||||
generate = async (options: ApiOptions & EmailDomainOptions) => {
|
|
||||||
if (!options.token || options.token === "") {
|
|
||||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.ForwardEmail.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (!options.domain || options.domain === "") {
|
|
||||||
const error = this.i18nService.t("forwarderNoDomain", Forwarders.ForwardEmail.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `https://api.forwardemail.net/v1/domains/${options.domain}/aliases`;
|
|
||||||
|
|
||||||
let descriptionId = "forwarderGeneratedByWithWebsite";
|
|
||||||
if (!options.website || options.website === "") {
|
|
||||||
descriptionId = "forwarderGeneratedBy";
|
|
||||||
}
|
|
||||||
const description = this.i18nService.t(descriptionId, options.website ?? "");
|
|
||||||
|
|
||||||
const request = new Request(url, {
|
|
||||||
redirect: "manual",
|
|
||||||
cache: "no-store",
|
|
||||||
method: "POST",
|
|
||||||
headers: new Headers({
|
|
||||||
Authorization: "Basic " + Utils.fromUtf8ToB64(options.token + ":"),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}),
|
|
||||||
body: JSON.stringify({
|
|
||||||
labels: options.website,
|
|
||||||
description,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await this.apiService.nativeFetch(request);
|
|
||||||
const json = await response.json();
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
|
||||||
const messageKey =
|
|
||||||
"message" in json ? "forwaderInvalidTokenWithMessage" : "forwaderInvalidToken";
|
|
||||||
const error = this.i18nService.t(messageKey, Forwarders.ForwardEmail.name, json.message);
|
|
||||||
throw error;
|
|
||||||
} else if (response.status === 200 || response.status === 201) {
|
|
||||||
const { name, domain } = await response.json();
|
|
||||||
const domainPart = domain?.name || options.domain;
|
|
||||||
return `${name}@${domainPart}`;
|
|
||||||
} else if (json?.message) {
|
|
||||||
const error = this.i18nService.t(
|
|
||||||
"forwarderError",
|
|
||||||
Forwarders.ForwardEmail.name,
|
|
||||||
json.message,
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
} else if (json?.error) {
|
|
||||||
const error = this.i18nService.t("forwarderError", Forwarders.ForwardEmail.name, json.error);
|
|
||||||
throw error;
|
|
||||||
} else {
|
|
||||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.ForwardEmail.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DefaultOptions = Object.freeze({
|
|
||||||
website: null,
|
|
||||||
token: "",
|
|
||||||
domain: "",
|
|
||||||
});
|
|
@ -1,22 +0,0 @@
|
|||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
|
||||||
import { I18nService } from "@bitwarden/common/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,209 +0,0 @@
|
|||||||
import { firstValueFrom } from "rxjs";
|
|
||||||
|
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultSimpleLoginOptions } from "../../data";
|
|
||||||
import { SIMPLE_LOGIN_FORWARDER } from "../storage";
|
|
||||||
|
|
||||||
import { mockApiService, mockI18nService } from "./mocks.jest";
|
|
||||||
import { SimpleLoginForwarder } from "./simple-login";
|
|
||||||
|
|
||||||
const SomeUser = "some user" as UserId;
|
|
||||||
|
|
||||||
describe("SimpleLogin Forwarder", () => {
|
|
||||||
it("key returns the Simple Login forwarder key", () => {
|
|
||||||
const forwarder = new SimpleLoginForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
expect(forwarder.key).toBe(SIMPLE_LOGIN_FORWARDER);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("defaults$", () => {
|
|
||||||
it("should return the default subaddress options", async () => {
|
|
||||||
const strategy = new SimpleLoginForwarder(null, null, null, null, null);
|
|
||||||
|
|
||||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
|
||||||
|
|
||||||
expect(result).toEqual(DefaultSimpleLoginOptions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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 SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token,
|
|
||||||
baseUrl: "https://api.example.com",
|
|
||||||
}),
|
|
||||||
).rejects.toEqual("forwaderInvalidToken");
|
|
||||||
|
|
||||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
|
||||||
expect(i18nService.t).toHaveBeenCalledWith(
|
|
||||||
"forwaderInvalidToken",
|
|
||||||
Forwarders.SimpleLogin.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 SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
baseUrl,
|
|
||||||
}),
|
|
||||||
).rejects.toEqual("forwarderNoUrl");
|
|
||||||
|
|
||||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
|
||||||
expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.SimpleLogin.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 SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await forwarder.generate({
|
|
||||||
website,
|
|
||||||
token: "token",
|
|
||||||
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 (alias, status) => {
|
|
||||||
const apiService = mockApiService(status, { alias });
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
const result = await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
baseUrl: "https://api.example.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual(alias);
|
|
||||||
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 SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
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.SimpleLogin.name,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([{}, null])(
|
|
||||||
"throws an unknown error if the request fails and no status (=%p) is provided",
|
|
||||||
async (body) => {
|
|
||||||
const apiService = mockApiService(500, body);
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
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.SimpleLogin.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, error) => {
|
|
||||||
const apiService = mockApiService(statusCode, { error });
|
|
||||||
const i18nService = mockI18nService();
|
|
||||||
|
|
||||||
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () =>
|
|
||||||
await forwarder.generate({
|
|
||||||
website: null,
|
|
||||||
token: "token",
|
|
||||||
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.SimpleLogin.name,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,88 +0,0 @@
|
|||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
|
||||||
|
|
||||||
import { Forwarders, DefaultSimpleLoginOptions } from "../../data";
|
|
||||||
import { SelfHostedApiOptions } from "../../types";
|
|
||||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
|
||||||
import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../storage";
|
|
||||||
|
|
||||||
/** Generates a forwarding address for Simple Login */
|
|
||||||
export class SimpleLoginForwarder extends ForwarderGeneratorStrategy<SelfHostedApiOptions> {
|
|
||||||
/** Instantiates the forwarder
|
|
||||||
* @param apiService used for ajax requests to the forwarding service
|
|
||||||
* @param i18nService used to look up error strings
|
|
||||||
* @param encryptService protects sensitive forwarder options
|
|
||||||
* @param keyService looks up the user key when protecting data.
|
|
||||||
* @param stateProvider creates the durable state for options storage
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
private apiService: ApiService,
|
|
||||||
private i18nService: I18nService,
|
|
||||||
encryptService: EncryptService,
|
|
||||||
keyService: CryptoService,
|
|
||||||
stateProvider: StateProvider,
|
|
||||||
) {
|
|
||||||
super(encryptService, keyService, stateProvider, DefaultSimpleLoginOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// configuration
|
|
||||||
readonly key = SIMPLE_LOGIN_FORWARDER;
|
|
||||||
readonly rolloverKey = SIMPLE_LOGIN_BUFFER;
|
|
||||||
|
|
||||||
// request
|
|
||||||
generate = async (options: SelfHostedApiOptions) => {
|
|
||||||
if (!options.token || options.token === "") {
|
|
||||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (!options.baseUrl || options.baseUrl === "") {
|
|
||||||
const error = this.i18nService.t("forwarderNoUrl", Forwarders.SimpleLogin.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = options.baseUrl + "/api/alias/random/new";
|
|
||||||
let noteId = "forwarderGeneratedBy";
|
|
||||||
if (options.website && options.website !== "") {
|
|
||||||
url += "?hostname=" + options.website;
|
|
||||||
noteId = "forwarderGeneratedByWithWebsite";
|
|
||||||
}
|
|
||||||
const note = this.i18nService.t(noteId, options.website ?? "");
|
|
||||||
|
|
||||||
const request = new Request(url, {
|
|
||||||
redirect: "manual",
|
|
||||||
cache: "no-store",
|
|
||||||
method: "POST",
|
|
||||||
headers: new Headers({
|
|
||||||
Authentication: options.token,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}),
|
|
||||||
body: JSON.stringify({ note }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await this.apiService.nativeFetch(request);
|
|
||||||
if (response.status === 401) {
|
|
||||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
if (response.status === 200 || response.status === 201) {
|
|
||||||
return json.alias;
|
|
||||||
} else if (json?.error) {
|
|
||||||
const error = this.i18nService.t("forwarderError", Forwarders.SimpleLogin.name, json.error);
|
|
||||||
throw error;
|
|
||||||
} else {
|
|
||||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.SimpleLogin.name);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DefaultOptions = Object.freeze({
|
|
||||||
website: null,
|
|
||||||
baseUrl: "https://app.simplelogin.io",
|
|
||||||
token: "",
|
|
||||||
});
|
|
@ -1,11 +1,6 @@
|
|||||||
|
export { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy";
|
||||||
export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy";
|
export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy";
|
||||||
export { PasswordGeneratorStrategy } from "./password-generator-strategy";
|
export { PasswordGeneratorStrategy } from "./password-generator-strategy";
|
||||||
export { CatchallGeneratorStrategy } from "./catchall-generator-strategy";
|
export { CatchallGeneratorStrategy } from "./catchall-generator-strategy";
|
||||||
export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy";
|
export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy";
|
||||||
export { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy";
|
export { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy";
|
||||||
export { AddyIoForwarder } from "./forwarders/addy-io";
|
|
||||||
export { DuckDuckGoForwarder } from "./forwarders/duck-duck-go";
|
|
||||||
export { FastmailForwarder } from "./forwarders/fastmail";
|
|
||||||
export { FirefoxRelayForwarder } from "./forwarders/firefox-relay";
|
|
||||||
export { ForwardEmailForwarder } from "./forwarders/forward-email";
|
|
||||||
export { SimpleLoginForwarder } from "./forwarders/simple-login";
|
|
||||||
|
@ -0,0 +1,60 @@
|
|||||||
|
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
|
import { OptionsClassifier } from "./options-classifier";
|
||||||
|
|
||||||
|
type SomeSettings = { foo: string };
|
||||||
|
type SomeOptions = IntegrationRequest & SomeSettings;
|
||||||
|
|
||||||
|
describe("OptionsClassifier", () => {
|
||||||
|
describe("classify", () => {
|
||||||
|
it("classifies properties from its input to the secret", () => {
|
||||||
|
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
|
||||||
|
|
||||||
|
const result = classifier.classify({ foo: "bar", website: null });
|
||||||
|
|
||||||
|
expect(result.secret).toMatchObject({ foo: "bar" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the website property from the secret", () => {
|
||||||
|
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
|
||||||
|
|
||||||
|
const result = classifier.classify({ foo: "bar", website: "www.example.com" });
|
||||||
|
|
||||||
|
expect(result.secret).not.toHaveProperty("website");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no disclosed data", () => {
|
||||||
|
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
|
||||||
|
|
||||||
|
const result = classifier.classify({ foo: "bar", website: "www.example.com" });
|
||||||
|
|
||||||
|
expect(result.disclosed).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("declassify", () => {
|
||||||
|
it("copies properties from secret to its output", () => {
|
||||||
|
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
|
||||||
|
|
||||||
|
const result = classifier.declassify(null, { foo: "bar" });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ foo: "bar" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a website property to its output", () => {
|
||||||
|
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
|
||||||
|
|
||||||
|
const result = classifier.declassify(null, { foo: "bar" });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ website: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores disclosed data", () => {
|
||||||
|
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
|
||||||
|
|
||||||
|
const result = classifier.declassify({ foo: "biz" }, { foo: "bar" });
|
||||||
|
|
||||||
|
expect(result).toEqual({ foo: "bar", website: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,44 @@
|
|||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { Classifier } from "@bitwarden/common/tools/state/classifier";
|
||||||
|
|
||||||
|
/** Classifies an object by excluding IntegrationRequest parameters.
|
||||||
|
*/
|
||||||
|
export class OptionsClassifier<
|
||||||
|
Settings,
|
||||||
|
Options extends IntegrationRequest & Settings = IntegrationRequest & Settings,
|
||||||
|
> implements Classifier<Options, Record<string, never>, Settings>
|
||||||
|
{
|
||||||
|
/** Partitions `secret` into its disclosed properties and secret properties.
|
||||||
|
* @param value The object to partition
|
||||||
|
* @returns an object that classifies secrets.
|
||||||
|
* The `disclosed` member is new and contains disclosed properties.
|
||||||
|
* The `secret` member is a copy of the secret parameter, including its
|
||||||
|
* prototype, with all disclosed and excluded properties deleted.
|
||||||
|
*/
|
||||||
|
classify(value: Options) {
|
||||||
|
const secret = JSON.parse(JSON.stringify(value));
|
||||||
|
delete secret.website;
|
||||||
|
const disclosed: Record<string, never> = {};
|
||||||
|
return { disclosed, secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges the properties of `secret` and `disclosed`. When `secret` and
|
||||||
|
* `disclosed` contain the same property, the `secret` property overrides
|
||||||
|
* the `disclosed` property.
|
||||||
|
* @param disclosed an object whose disclosed properties are merged into
|
||||||
|
* the output. Unknown properties are ignored.
|
||||||
|
* @param secret an objects whose properties are merged into the output.
|
||||||
|
* Excluded properties are ignored. Unknown properties are retained.
|
||||||
|
* @returns a new object containing the merged data.
|
||||||
|
*
|
||||||
|
* @remarks Declassified data is always jsonified--the purpose of classifying it is
|
||||||
|
* to Jsonify it,
|
||||||
|
* which causes type conversions.
|
||||||
|
*/
|
||||||
|
declassify(_disclosed: Jsonify<Record<keyof Settings, never>>, secret: Jsonify<Settings>) {
|
||||||
|
const result = { ...(secret as any), website: null };
|
||||||
|
return result as Jsonify<Options>;
|
||||||
|
}
|
||||||
|
}
|
@ -6,7 +6,7 @@ import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions, Polici
|
|||||||
import { PasswordRandomizer } from "../engine";
|
import { PasswordRandomizer } from "../engine";
|
||||||
import { mapPolicyToEvaluator } from "../rx";
|
import { mapPolicyToEvaluator } from "../rx";
|
||||||
import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
|
import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
|
||||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
import { observe$PerUserId, sharedStateByUserId } from "../util";
|
||||||
|
|
||||||
import { PASSPHRASE_SETTINGS } from "./storage";
|
import { PASSPHRASE_SETTINGS } from "./storage";
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ export class PassphraseGeneratorStrategy
|
|||||||
|
|
||||||
// configuration
|
// configuration
|
||||||
durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider);
|
durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider);
|
||||||
defaults$ = clone$PerUserId(DefaultPassphraseGenerationOptions);
|
defaults$ = observe$PerUserId(() => DefaultPassphraseGenerationOptions);
|
||||||
readonly policy = PolicyType.PasswordGenerator;
|
readonly policy = PolicyType.PasswordGenerator;
|
||||||
toEvaluator() {
|
toEvaluator() {
|
||||||
return mapPolicyToEvaluator(Policies.Passphrase);
|
return mapPolicyToEvaluator(Policies.Passphrase);
|
||||||
|
@ -6,7 +6,7 @@ import { Policies, DefaultPasswordGenerationOptions } from "../data";
|
|||||||
import { PasswordRandomizer } from "../engine";
|
import { PasswordRandomizer } from "../engine";
|
||||||
import { mapPolicyToEvaluator } from "../rx";
|
import { mapPolicyToEvaluator } from "../rx";
|
||||||
import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types";
|
import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types";
|
||||||
import { clone$PerUserId, sharedStateByUserId, sum } from "../util";
|
import { observe$PerUserId, sharedStateByUserId, sum } from "../util";
|
||||||
|
|
||||||
import { PASSWORD_SETTINGS } from "./storage";
|
import { PASSWORD_SETTINGS } from "./storage";
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ export class PasswordGeneratorStrategy
|
|||||||
|
|
||||||
// configuration
|
// configuration
|
||||||
durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider);
|
durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider);
|
||||||
defaults$ = clone$PerUserId(DefaultPasswordGenerationOptions);
|
defaults$ = observe$PerUserId(() => DefaultPasswordGenerationOptions);
|
||||||
readonly policy = PolicyType.PasswordGenerator;
|
readonly policy = PolicyType.PasswordGenerator;
|
||||||
toEvaluator() {
|
toEvaluator() {
|
||||||
return mapPolicyToEvaluator(Policies.Password);
|
return mapPolicyToEvaluator(Policies.Password);
|
||||||
|
@ -4,18 +4,6 @@ import {
|
|||||||
SUBADDRESS_SETTINGS,
|
SUBADDRESS_SETTINGS,
|
||||||
PASSPHRASE_SETTINGS,
|
PASSPHRASE_SETTINGS,
|
||||||
PASSWORD_SETTINGS,
|
PASSWORD_SETTINGS,
|
||||||
SIMPLE_LOGIN_FORWARDER,
|
|
||||||
FORWARD_EMAIL_FORWARDER,
|
|
||||||
FIREFOX_RELAY_FORWARDER,
|
|
||||||
FASTMAIL_FORWARDER,
|
|
||||||
DUCK_DUCK_GO_FORWARDER,
|
|
||||||
ADDY_IO_FORWARDER,
|
|
||||||
ADDY_IO_BUFFER,
|
|
||||||
DUCK_DUCK_GO_BUFFER,
|
|
||||||
FASTMAIL_BUFFER,
|
|
||||||
FIREFOX_RELAY_BUFFER,
|
|
||||||
FORWARD_EMAIL_BUFFER,
|
|
||||||
SIMPLE_LOGIN_BUFFER,
|
|
||||||
} from "./storage";
|
} from "./storage";
|
||||||
|
|
||||||
describe("Key definitions", () => {
|
describe("Key definitions", () => {
|
||||||
@ -58,112 +46,4 @@ describe("Key definitions", () => {
|
|||||||
expect(result).toBe(value);
|
expect(result).toBe(value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ADDY_IO_FORWARDER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
const result = ADDY_IO_FORWARDER.deserializer(value);
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("DUCK_DUCK_GO_FORWARDER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
const result = DUCK_DUCK_GO_FORWARDER.deserializer(value);
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FASTMAIL_FORWARDER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
const result = FASTMAIL_FORWARDER.deserializer(value);
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FIREFOX_RELAY_FORWARDER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
const result = FIREFOX_RELAY_FORWARDER.deserializer(value);
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FORWARD_EMAIL_FORWARDER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
const result = FORWARD_EMAIL_FORWARDER.deserializer(value);
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SIMPLE_LOGIN_FORWARDER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
const result = SIMPLE_LOGIN_FORWARDER.deserializer(value);
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ADDY_IO_BUFFER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
|
|
||||||
const result = ADDY_IO_BUFFER.options.deserializer(value);
|
|
||||||
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("DUCK_DUCK_GO_BUFFER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
|
|
||||||
const result = DUCK_DUCK_GO_BUFFER.options.deserializer(value);
|
|
||||||
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FASTMAIL_BUFFER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
|
|
||||||
const result = FASTMAIL_BUFFER.options.deserializer(value);
|
|
||||||
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FIREFOX_RELAY_BUFFER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
|
|
||||||
const result = FIREFOX_RELAY_BUFFER.options.deserializer(value);
|
|
||||||
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FORWARD_EMAIL_BUFFER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
|
|
||||||
const result = FORWARD_EMAIL_BUFFER.options.deserializer(value);
|
|
||||||
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SIMPLE_LOGIN_BUFFER", () => {
|
|
||||||
it("should pass through deserialization", () => {
|
|
||||||
const value: any = {};
|
|
||||||
|
|
||||||
const result = SIMPLE_LOGIN_BUFFER.options.deserializer(value);
|
|
||||||
|
|
||||||
expect(result).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -1,15 +1,10 @@
|
|||||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PassphraseGenerationOptions,
|
PassphraseGenerationOptions,
|
||||||
PasswordGenerationOptions,
|
PasswordGenerationOptions,
|
||||||
CatchallGenerationOptions,
|
CatchallGenerationOptions,
|
||||||
EffUsernameGenerationOptions,
|
EffUsernameGenerationOptions,
|
||||||
ApiOptions,
|
|
||||||
EmailDomainOptions,
|
|
||||||
EmailPrefixOptions,
|
|
||||||
SelfHostedApiOptions,
|
|
||||||
SubaddressGenerationOptions,
|
SubaddressGenerationOptions,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
@ -62,123 +57,3 @@ export const SUBADDRESS_SETTINGS = new UserKeyDefinition<SubaddressGenerationOpt
|
|||||||
clearOn: [],
|
clearOn: [],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** backing store configuration for {@link Forwarders.AddyIo} */
|
|
||||||
export const ADDY_IO_FORWARDER = new UserKeyDefinition<SelfHostedApiOptions & EmailDomainOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"addyIoForwarder",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link Forwarders.DuckDuckGo} */
|
|
||||||
export const DUCK_DUCK_GO_FORWARDER = new UserKeyDefinition<ApiOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"duckDuckGoForwarder",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link Forwarders.FastMail} */
|
|
||||||
export const FASTMAIL_FORWARDER = new UserKeyDefinition<ApiOptions & EmailPrefixOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"fastmailForwarder",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link Forwarders.FireFoxRelay} */
|
|
||||||
export const FIREFOX_RELAY_FORWARDER = new UserKeyDefinition<ApiOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"firefoxRelayForwarder",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link Forwarders.ForwardEmail} */
|
|
||||||
export const FORWARD_EMAIL_FORWARDER = new UserKeyDefinition<ApiOptions & EmailDomainOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"forwardEmailForwarder",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link forwarders.SimpleLogin} */
|
|
||||||
export const SIMPLE_LOGIN_FORWARDER = new UserKeyDefinition<SelfHostedApiOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"simpleLoginForwarder",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link Forwarders.AddyIo} */
|
|
||||||
export const ADDY_IO_BUFFER = new BufferedKeyDefinition<SelfHostedApiOptions & EmailDomainOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"addyIoBuffer",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: ["logout"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link Forwarders.DuckDuckGo} */
|
|
||||||
export const DUCK_DUCK_GO_BUFFER = new BufferedKeyDefinition<ApiOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"duckDuckGoBuffer",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: ["logout"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link Forwarders.FastMail} */
|
|
||||||
export const FASTMAIL_BUFFER = new BufferedKeyDefinition<ApiOptions & EmailPrefixOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"fastmailBuffer",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: ["logout"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link Forwarders.FireFoxRelay} */
|
|
||||||
export const FIREFOX_RELAY_BUFFER = new BufferedKeyDefinition<ApiOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"firefoxRelayBuffer",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: ["logout"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link Forwarders.ForwardEmail} */
|
|
||||||
export const FORWARD_EMAIL_BUFFER = new BufferedKeyDefinition<ApiOptions & EmailDomainOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"forwardEmailBuffer",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: ["logout"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** backing store configuration for {@link forwarders.SimpleLogin} */
|
|
||||||
export const SIMPLE_LOGIN_BUFFER = new BufferedKeyDefinition<SelfHostedApiOptions>(
|
|
||||||
GENERATOR_DISK,
|
|
||||||
"simpleLoginBuffer",
|
|
||||||
{
|
|
||||||
deserializer: (value) => value,
|
|
||||||
clearOn: ["logout"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
@ -6,7 +6,7 @@ import { DefaultSubaddressOptions } from "../data";
|
|||||||
import { EmailCalculator, EmailRandomizer } from "../engine";
|
import { EmailCalculator, EmailRandomizer } from "../engine";
|
||||||
import { newDefaultEvaluator } from "../rx";
|
import { newDefaultEvaluator } from "../rx";
|
||||||
import { SubaddressGenerationOptions, NoPolicy } from "../types";
|
import { SubaddressGenerationOptions, NoPolicy } from "../types";
|
||||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
import { observe$PerUserId, sharedStateByUserId } from "../util";
|
||||||
|
|
||||||
import { SUBADDRESS_SETTINGS } from "./storage";
|
import { SUBADDRESS_SETTINGS } from "./storage";
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ export class SubaddressGeneratorStrategy
|
|||||||
|
|
||||||
// configuration
|
// configuration
|
||||||
durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider);
|
durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider);
|
||||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
defaults$ = observe$PerUserId(() => this.defaultOptions);
|
||||||
toEvaluator = newDefaultEvaluator<SubaddressGenerationOptions>();
|
toEvaluator = newDefaultEvaluator<SubaddressGenerationOptions>();
|
||||||
readonly policy = PolicyType.PasswordGenerator;
|
readonly policy = PolicyType.PasswordGenerator;
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { RequestOptions } from "./forwarder-options";
|
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
import { UsernameGenerationMode } from "./generator-options";
|
import { UsernameGenerationMode } from "./generator-options";
|
||||||
|
|
||||||
/** Settings supported when generating an email subaddress */
|
/** Settings supported when generating an email subaddress */
|
||||||
@ -11,4 +12,4 @@ export type CatchallGenerationOptions = {
|
|||||||
* is `jd`, then the generated email address will be `jd@mydomain.io`
|
* is `jd`, then the generated email address will be `jd@mydomain.io`
|
||||||
*/
|
*/
|
||||||
catchallDomain?: string;
|
catchallDomain?: string;
|
||||||
} & RequestOptions;
|
} & IntegrationRequest;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { RequestOptions } from "./forwarder-options";
|
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
/** Settings supported when generating a username using the EFF word list */
|
/** Settings supported when generating a username using the EFF word list */
|
||||||
export type EffUsernameGenerationOptions = {
|
export type EffUsernameGenerationOptions = {
|
||||||
@ -7,4 +7,4 @@ export type EffUsernameGenerationOptions = {
|
|||||||
|
|
||||||
/** when true, a random number is appended to the username */
|
/** when true, a random number is appended to the username */
|
||||||
wordIncludeNumber?: boolean;
|
wordIncludeNumber?: boolean;
|
||||||
} & RequestOptions;
|
} & IntegrationRequest;
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
|
import { IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
|
import {
|
||||||
|
ApiSettings,
|
||||||
|
IntegrationRequest,
|
||||||
|
SelfHostedApiSettings,
|
||||||
|
} from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
|
import { EmailDomainSettings, EmailPrefixSettings } from "../engine";
|
||||||
|
|
||||||
/** Identifiers for email forwarding services.
|
/** Identifiers for email forwarding services.
|
||||||
* @remarks These are used to select forwarder-specific options.
|
* @remarks These are used to select forwarder-specific options.
|
||||||
* The must be kept in sync with the forwarder implementations.
|
* The must be kept in sync with the forwarder implementations.
|
||||||
*/
|
*/
|
||||||
export type ForwarderId =
|
export type ForwarderId = IntegrationId;
|
||||||
| "anonaddy"
|
|
||||||
| "duckduckgo"
|
|
||||||
| "fastmail"
|
|
||||||
| "firefoxrelay"
|
|
||||||
| "forwardemail"
|
|
||||||
| "simplelogin";
|
|
||||||
|
|
||||||
/** Metadata format for email forwarding services. */
|
/** Metadata format for email forwarding services. */
|
||||||
export type ForwarderMetadata = {
|
export type ForwarderMetadata = {
|
||||||
@ -23,50 +26,13 @@ export type ForwarderMetadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Options common to all forwarder APIs */
|
/** Options common to all forwarder APIs */
|
||||||
export type ApiOptions = {
|
export type ApiOptions = ApiSettings & IntegrationRequest;
|
||||||
/** bearer token that authenticates bitwarden to the forwarder.
|
|
||||||
* This is required to issue an API request.
|
|
||||||
*/
|
|
||||||
token?: string;
|
|
||||||
} & RequestOptions;
|
|
||||||
|
|
||||||
/** Options that provide contextual information about the application state
|
|
||||||
* when a forwarder is invoked.
|
|
||||||
* @remarks these fields should always be omitted when saving options.
|
|
||||||
*/
|
|
||||||
export type RequestOptions = {
|
|
||||||
/** @param website The domain of the website the generated email is used
|
|
||||||
* within. This should be set to `null` when the request is not specific
|
|
||||||
* to any website.
|
|
||||||
*/
|
|
||||||
website: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Api configuration for forwarders that support self-hosted installations. */
|
/** Api configuration for forwarders that support self-hosted installations. */
|
||||||
export type SelfHostedApiOptions = ApiOptions & {
|
export type SelfHostedApiOptions = SelfHostedApiSettings & IntegrationRequest;
|
||||||
/** The base URL of the forwarder's API.
|
|
||||||
* When this is empty, the forwarder's default production API is used.
|
|
||||||
*/
|
|
||||||
baseUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Api configuration for forwarders that support custom domains. */
|
/** Api configuration for forwarders that support custom domains. */
|
||||||
export type EmailDomainOptions = {
|
export type EmailDomainOptions = EmailDomainSettings;
|
||||||
/** The domain part of the generated email address.
|
|
||||||
* @remarks The domain should be authorized by the forwarder before
|
|
||||||
* submitting a request through bitwarden.
|
|
||||||
* @example If the domain is `domain.io` and the generated username
|
|
||||||
* is `jd`, then the generated email address will be `jd@mydomain.io`
|
|
||||||
*/
|
|
||||||
domain: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Api configuration for forwarders that support custom email parts. */
|
/** Api configuration for forwarders that support custom email parts. */
|
||||||
export type EmailPrefixOptions = EmailDomainOptions & {
|
export type EmailPrefixOptions = EmailDomainSettings & EmailPrefixSettings;
|
||||||
/** A prefix joined to the generated email address' username.
|
|
||||||
* @example If the prefix is `foo`, the generated username is `bar`,
|
|
||||||
* and the domain is `domain.io`, then the generated email address is `
|
|
||||||
* then the generated username is `foobar@domain.io`.
|
|
||||||
*/
|
|
||||||
prefix: string;
|
|
||||||
};
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { RequestOptions } from "./forwarder-options";
|
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
import { UsernameGenerationMode } from "./generator-options";
|
import { UsernameGenerationMode } from "./generator-options";
|
||||||
|
|
||||||
/** Settings supported when generating an email subaddress */
|
/** Settings supported when generating an email subaddress */
|
||||||
@ -8,4 +9,4 @@ export type SubaddressGenerationOptions = {
|
|||||||
|
|
||||||
/** the email address the subaddress is applied to. */
|
/** the email address the subaddress is applied to. */
|
||||||
subaddressEmail?: string;
|
subaddressEmail?: string;
|
||||||
} & RequestOptions;
|
} & IntegrationRequest;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SingleUserState,
|
SingleUserState,
|
||||||
@ -8,14 +8,17 @@ import {
|
|||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
/** construct a method that outputs a copy of `defaultValue` as an observable. */
|
/** construct a method that outputs a copy of `defaultValue` as an observable. */
|
||||||
export function clone$PerUserId<Value>(defaultValue: Value) {
|
export function observe$PerUserId<Value>(
|
||||||
|
create: () => Partial<Value>,
|
||||||
|
): (key: UserId) => Observable<Value> {
|
||||||
const _subjects = new Map<UserId, BehaviorSubject<Value>>();
|
const _subjects = new Map<UserId, BehaviorSubject<Value>>();
|
||||||
|
|
||||||
return (key: UserId) => {
|
return (key: UserId) => {
|
||||||
let value = _subjects.get(key);
|
let value = _subjects.get(key);
|
||||||
|
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
value = new BehaviorSubject({ ...defaultValue });
|
const initialValue = create();
|
||||||
|
value = new BehaviorSubject({ ...initialValue } as Value);
|
||||||
_subjects.set(key, value);
|
_subjects.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,7 +5,8 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se
|
|||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
import { engine, services, strategies } from "@bitwarden/generator-core";
|
import { RestClient } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { engine, services, strategies, Integrations } from "@bitwarden/generator-core";
|
||||||
import { DefaultGeneratorNavigationService } from "@bitwarden/generator-navigation";
|
import { DefaultGeneratorNavigationService } from "@bitwarden/generator-navigation";
|
||||||
|
|
||||||
import { LegacyUsernameGenerationService } from "./legacy-username-generation.service";
|
import { LegacyUsernameGenerationService } from "./legacy-username-generation.service";
|
||||||
@ -17,12 +18,7 @@ const {
|
|||||||
CatchallGeneratorStrategy,
|
CatchallGeneratorStrategy,
|
||||||
SubaddressGeneratorStrategy,
|
SubaddressGeneratorStrategy,
|
||||||
EffUsernameGeneratorStrategy,
|
EffUsernameGeneratorStrategy,
|
||||||
AddyIoForwarder,
|
ForwarderGeneratorStrategy,
|
||||||
DuckDuckGoForwarder,
|
|
||||||
FastmailForwarder,
|
|
||||||
FirefoxRelayForwarder,
|
|
||||||
ForwardEmailForwarder,
|
|
||||||
SimpleLoginForwarder,
|
|
||||||
} = strategies;
|
} = strategies;
|
||||||
|
|
||||||
export function legacyUsernameGenerationServiceFactory(
|
export function legacyUsernameGenerationServiceFactory(
|
||||||
@ -35,6 +31,7 @@ export function legacyUsernameGenerationServiceFactory(
|
|||||||
stateProvider: StateProvider,
|
stateProvider: StateProvider,
|
||||||
): UsernameGenerationServiceAbstraction {
|
): UsernameGenerationServiceAbstraction {
|
||||||
const randomizer = new CryptoServiceRandomizer(cryptoService);
|
const randomizer = new CryptoServiceRandomizer(cryptoService);
|
||||||
|
const restClient = new RestClient(apiService, i18nService);
|
||||||
const usernameRandomizer = new UsernameRandomizer(randomizer);
|
const usernameRandomizer = new UsernameRandomizer(randomizer);
|
||||||
const emailRandomizer = new EmailRandomizer(randomizer);
|
const emailRandomizer = new EmailRandomizer(randomizer);
|
||||||
const emailCalculator = new EmailCalculator();
|
const emailCalculator = new EmailCalculator();
|
||||||
@ -55,23 +52,45 @@ export function legacyUsernameGenerationServiceFactory(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const addyIo = new DefaultGeneratorService(
|
const addyIo = new DefaultGeneratorService(
|
||||||
new AddyIoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
new ForwarderGeneratorStrategy(
|
||||||
|
Integrations.AddyIo,
|
||||||
|
restClient,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
stateProvider,
|
||||||
|
),
|
||||||
policyService,
|
policyService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const duckDuckGo = new DefaultGeneratorService(
|
const duckDuckGo = new DefaultGeneratorService(
|
||||||
new DuckDuckGoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
new ForwarderGeneratorStrategy(
|
||||||
|
Integrations.DuckDuckGo,
|
||||||
|
restClient,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
stateProvider,
|
||||||
|
),
|
||||||
policyService,
|
policyService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const fastmail = new DefaultGeneratorService(
|
const fastmail = new DefaultGeneratorService(
|
||||||
new FastmailForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
new ForwarderGeneratorStrategy(
|
||||||
|
Integrations.Fastmail,
|
||||||
|
restClient,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
stateProvider,
|
||||||
|
),
|
||||||
policyService,
|
policyService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const firefoxRelay = new DefaultGeneratorService(
|
const firefoxRelay = new DefaultGeneratorService(
|
||||||
new FirefoxRelayForwarder(
|
new ForwarderGeneratorStrategy(
|
||||||
apiService,
|
Integrations.FirefoxRelay,
|
||||||
|
restClient,
|
||||||
i18nService,
|
i18nService,
|
||||||
encryptService,
|
encryptService,
|
||||||
cryptoService,
|
cryptoService,
|
||||||
@ -81,8 +100,9 @@ export function legacyUsernameGenerationServiceFactory(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const forwardEmail = new DefaultGeneratorService(
|
const forwardEmail = new DefaultGeneratorService(
|
||||||
new ForwardEmailForwarder(
|
new ForwarderGeneratorStrategy(
|
||||||
apiService,
|
Integrations.ForwardEmail,
|
||||||
|
restClient,
|
||||||
i18nService,
|
i18nService,
|
||||||
encryptService,
|
encryptService,
|
||||||
cryptoService,
|
cryptoService,
|
||||||
@ -92,7 +112,14 @@ export function legacyUsernameGenerationServiceFactory(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const simpleLogin = new DefaultGeneratorService(
|
const simpleLogin = new DefaultGeneratorService(
|
||||||
new SimpleLoginForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
new ForwarderGeneratorStrategy(
|
||||||
|
Integrations.SimpleLogin,
|
||||||
|
restClient,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
stateProvider,
|
||||||
|
),
|
||||||
policyService,
|
policyService,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
import { of } from "rxjs";
|
import { of } from "rxjs";
|
||||||
|
|
||||||
|
import { IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import {
|
import {
|
||||||
GeneratorService,
|
GeneratorService,
|
||||||
@ -185,7 +186,7 @@ describe("LegacyPasswordGenerationService", () => {
|
|||||||
const navigation = createNavigationGenerator({
|
const navigation = createNavigationGenerator({
|
||||||
type: "passphrase",
|
type: "passphrase",
|
||||||
username: "word",
|
username: "word",
|
||||||
forwarder: "simplelogin",
|
forwarder: "simplelogin" as IntegrationId,
|
||||||
});
|
});
|
||||||
const accountService = mockAccountServiceWith(SomeUser);
|
const accountService = mockAccountServiceWith(SomeUser);
|
||||||
const generator = new LegacyPasswordGenerationService(
|
const generator = new LegacyPasswordGenerationService(
|
||||||
@ -496,7 +497,7 @@ describe("LegacyPasswordGenerationService", () => {
|
|||||||
const navigation = createNavigationGenerator({
|
const navigation = createNavigationGenerator({
|
||||||
type: "password",
|
type: "password",
|
||||||
username: "forwarded",
|
username: "forwarded",
|
||||||
forwarder: "firefoxrelay",
|
forwarder: "firefoxrelay" as IntegrationId,
|
||||||
});
|
});
|
||||||
const accountService = mockAccountServiceWith(SomeUser);
|
const accountService = mockAccountServiceWith(SomeUser);
|
||||||
const generator = new LegacyPasswordGenerationService(
|
const generator = new LegacyPasswordGenerationService(
|
||||||
|
@ -23,6 +23,7 @@ import {
|
|||||||
DefaultSubaddressOptions,
|
DefaultSubaddressOptions,
|
||||||
SubaddressGenerationOptions,
|
SubaddressGenerationOptions,
|
||||||
policies,
|
policies,
|
||||||
|
Integrations,
|
||||||
} from "@bitwarden/generator-core";
|
} from "@bitwarden/generator-core";
|
||||||
import {
|
import {
|
||||||
GeneratorNavigationPolicy,
|
GeneratorNavigationPolicy,
|
||||||
@ -724,7 +725,7 @@ describe("LegacyUsernameGenerationService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
options.type = "forwarded";
|
options.type = "forwarded";
|
||||||
options.forwardedService = "anonaddy";
|
options.forwardedService = Integrations.AddyIo.id;
|
||||||
await generator.saveOptions(options);
|
await generator.saveOptions(options);
|
||||||
|
|
||||||
expect(addyIo.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
expect(addyIo.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||||
@ -735,7 +736,7 @@ describe("LegacyUsernameGenerationService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
options.type = "forwarded";
|
options.type = "forwarded";
|
||||||
options.forwardedService = "duckduckgo";
|
options.forwardedService = Integrations.DuckDuckGo.id;
|
||||||
await generator.saveOptions(options);
|
await generator.saveOptions(options);
|
||||||
|
|
||||||
expect(duckDuckGo.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
expect(duckDuckGo.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||||
@ -744,7 +745,7 @@ describe("LegacyUsernameGenerationService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
options.type = "forwarded";
|
options.type = "forwarded";
|
||||||
options.forwardedService = "fastmail";
|
options.forwardedService = Integrations.Fastmail.id;
|
||||||
await generator.saveOptions(options);
|
await generator.saveOptions(options);
|
||||||
|
|
||||||
expect(fastmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
expect(fastmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||||
@ -753,7 +754,7 @@ describe("LegacyUsernameGenerationService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
options.type = "forwarded";
|
options.type = "forwarded";
|
||||||
options.forwardedService = "firefoxrelay";
|
options.forwardedService = Integrations.FirefoxRelay.id;
|
||||||
await generator.saveOptions(options);
|
await generator.saveOptions(options);
|
||||||
|
|
||||||
expect(firefoxRelay.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
expect(firefoxRelay.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||||
@ -762,7 +763,7 @@ describe("LegacyUsernameGenerationService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
options.type = "forwarded";
|
options.type = "forwarded";
|
||||||
options.forwardedService = "forwardemail";
|
options.forwardedService = Integrations.ForwardEmail.id;
|
||||||
await generator.saveOptions(options);
|
await generator.saveOptions(options);
|
||||||
|
|
||||||
expect(forwardEmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
expect(forwardEmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||||
@ -772,7 +773,7 @@ describe("LegacyUsernameGenerationService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
options.type = "forwarded";
|
options.type = "forwarded";
|
||||||
options.forwardedService = "simplelogin";
|
options.forwardedService = Integrations.SimpleLogin.id;
|
||||||
await generator.saveOptions(options);
|
await generator.saveOptions(options);
|
||||||
|
|
||||||
expect(simpleLogin.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
expect(simpleLogin.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs";
|
import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs";
|
||||||
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import {
|
import {
|
||||||
ApiOptions,
|
ApiOptions,
|
||||||
EmailDomainOptions,
|
EmailDomainOptions,
|
||||||
EmailPrefixOptions,
|
EmailPrefixOptions,
|
||||||
RequestOptions,
|
|
||||||
SelfHostedApiOptions,
|
SelfHostedApiOptions,
|
||||||
NoPolicy,
|
NoPolicy,
|
||||||
GeneratorService,
|
GeneratorService,
|
||||||
@ -30,12 +30,12 @@ type MappedOptions = {
|
|||||||
subaddress: SubaddressGenerationOptions;
|
subaddress: SubaddressGenerationOptions;
|
||||||
};
|
};
|
||||||
forwarders: {
|
forwarders: {
|
||||||
addyIo: SelfHostedApiOptions & EmailDomainOptions & RequestOptions;
|
addyIo: SelfHostedApiOptions & EmailDomainOptions & IntegrationRequest;
|
||||||
duckDuckGo: ApiOptions & RequestOptions;
|
duckDuckGo: ApiOptions & IntegrationRequest;
|
||||||
fastmail: ApiOptions & EmailPrefixOptions & RequestOptions;
|
fastmail: ApiOptions & EmailPrefixOptions & IntegrationRequest;
|
||||||
firefoxRelay: ApiOptions & RequestOptions;
|
firefoxRelay: ApiOptions & IntegrationRequest;
|
||||||
forwardEmail: ApiOptions & EmailDomainOptions & RequestOptions;
|
forwardEmail: ApiOptions & EmailDomainOptions & IntegrationRequest;
|
||||||
simpleLogin: SelfHostedApiOptions & RequestOptions;
|
simpleLogin: SelfHostedApiOptions & IntegrationRequest;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
import {
|
import {
|
||||||
ForwarderId,
|
ForwarderId,
|
||||||
RequestOptions,
|
|
||||||
CatchallGenerationOptions,
|
CatchallGenerationOptions,
|
||||||
EffUsernameGenerationOptions,
|
EffUsernameGenerationOptions,
|
||||||
SubaddressGenerationOptions,
|
SubaddressGenerationOptions,
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
export type UsernameGeneratorOptions = EffUsernameGenerationOptions &
|
export type UsernameGeneratorOptions = EffUsernameGenerationOptions &
|
||||||
SubaddressGenerationOptions &
|
SubaddressGenerationOptions &
|
||||||
CatchallGenerationOptions &
|
CatchallGenerationOptions &
|
||||||
RequestOptions & {
|
IntegrationRequest & {
|
||||||
type?: UsernameGeneratorType;
|
type?: UsernameGeneratorType;
|
||||||
forwardedService?: ForwarderId | "";
|
forwardedService?: ForwarderId | "";
|
||||||
forwardedAnonAddyApiToken?: string;
|
forwardedAnonAddyApiToken?: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user