diff --git a/integration-tests/aws-sdk/setup.ts b/integration-tests/aws-sdk/setup.ts index cb751df8..2023f164 100644 --- a/integration-tests/aws-sdk/setup.ts +++ b/integration-tests/aws-sdk/setup.ts @@ -53,7 +53,7 @@ export const withCognitoSdk = ( { cognitoClient, messageDelivery: mockCodeDelivery, - messages: new MessagesService(), + messages: new MessagesService(triggers), otp, triggers, }, diff --git a/src/__tests__/testDataBuilder.ts b/src/__tests__/testDataBuilder.ts new file mode 100644 index 00000000..e21ac2e5 --- /dev/null +++ b/src/__tests__/testDataBuilder.ts @@ -0,0 +1,11 @@ +import { User } from "../services/userPoolClient"; + +export const user = (): User => ({ + Attributes: [], + Enabled: true, + Password: "Password123!", + UserCreateDate: new Date().getTime(), + UserLastModifiedDate: new Date().getTime(), + Username: "Username", + UserStatus: "CONFIRMED", +}); diff --git a/src/server/defaults.ts b/src/server/defaults.ts index 856e19ee..668952bf 100644 --- a/src/server/defaults.ts +++ b/src/server/defaults.ts @@ -39,7 +39,7 @@ export const createDefaultServer = async (logger: Logger): Promise => { messageDelivery: new MessageDeliveryService( new ConsoleMessageSender(logger) ), - messages: new MessagesService(), + messages: new MessagesService(triggers), otp, triggers, }, diff --git a/src/services/lambda.test.ts b/src/services/lambda.test.ts index a51a7f1b..2eaa2190 100644 --- a/src/services/lambda.test.ts +++ b/src/services/lambda.test.ts @@ -47,11 +47,11 @@ describe("Lambda function invoker", () => { ).rejects.toEqual(new Error("UserMigration trigger not configured")); }); - describe("UserMigration_Authentication", () => { - it("invokes the lambda", async () => { + describe("when lambda successful", () => { + it("returns string payload as json", async () => { const response = Promise.resolve({ StatusCode: 200, - Payload: '{ "some": "json" }', + Payload: '{ "response": "value" }', }); mockLambdaClient.invoke.mockReturnValue({ promise: () => response, @@ -64,7 +64,7 @@ describe("Lambda function invoker", () => { MockLogger ); - await lambda.invoke("UserMigration", { + const result = await lambda.invoke("UserMigration", { clientId: "clientId", password: "password", triggerSource: "UserMigration_Authentication", @@ -73,32 +73,13 @@ describe("Lambda function invoker", () => { userAttributes: {}, }); - expect(mockLambdaClient.invoke).toHaveBeenCalledWith({ - FunctionName: "MyLambdaName", - InvocationType: "RequestResponse", - Payload: JSON.stringify({ - version: 0, - userName: "username", - callerContext: { awsSdkVersion: "2.656.0", clientId: "clientId" }, - region: "local", - userPoolId: "userPoolId", - triggerSource: "UserMigration_Authentication", - request: { - userAttributes: {}, - password: "password", - validationData: {}, - }, - response: {}, - }), - }); + expect(result).toEqual("value"); }); - }); - describe("when lambda successful", () => { - it("returns string payload as json", async () => { + it("returns Buffer payload as json", async () => { const response = Promise.resolve({ StatusCode: 200, - Payload: '{ "response": "value" }', + Payload: Buffer.from('{ "response": "value" }'), }); mockLambdaClient.invoke.mockReturnValue({ promise: () => response, @@ -122,11 +103,13 @@ describe("Lambda function invoker", () => { expect(result).toEqual("value"); }); + }); - it("returns Buffer payload as json", async () => { + describe("UserMigration_Authentication", () => { + it("invokes the lambda", async () => { const response = Promise.resolve({ StatusCode: 200, - Payload: Buffer.from('{ "response": "value" }'), + Payload: '{ "some": "json" }', }); mockLambdaClient.invoke.mockReturnValue({ promise: () => response, @@ -139,7 +122,7 @@ describe("Lambda function invoker", () => { MockLogger ); - const result = await lambda.invoke("UserMigration", { + await lambda.invoke("UserMigration", { clientId: "clientId", password: "password", triggerSource: "UserMigration_Authentication", @@ -148,7 +131,79 @@ describe("Lambda function invoker", () => { userAttributes: {}, }); - expect(result).toEqual("value"); + expect(mockLambdaClient.invoke).toHaveBeenCalledWith({ + FunctionName: "MyLambdaName", + InvocationType: "RequestResponse", + Payload: JSON.stringify({ + version: 0, + userName: "username", + callerContext: { awsSdkVersion: "2.656.0", clientId: "clientId" }, + region: "local", + userPoolId: "userPoolId", + triggerSource: "UserMigration_Authentication", + request: { + userAttributes: {}, + password: "password", + validationData: {}, + }, + response: {}, + }), + }); + }); + }); + + describe.each([ + "CustomMessage_SignUp", + "CustomMessage_AdminCreateUser", + "CustomMessage_ResendCode", + "CustomMessage_ForgotPassword", + "CustomMessage_UpdateUserAttribute", + "CustomMessage_VerifyUserAttribute", + "CustomMessage_Authentication", + ] as const)("%s", (source) => { + it("invokes the lambda function with the code parameter", async () => { + const response = Promise.resolve({ + StatusCode: 200, + Payload: '{ "some": "json" }', + }); + mockLambdaClient.invoke.mockReturnValue({ + promise: () => response, + } as any); + const lambda = new LambdaService( + { + CustomMessage: "MyLambdaName", + }, + mockLambdaClient, + MockLogger + ); + + await lambda.invoke("CustomMessage", { + clientId: "clientId", + code: "1234", + triggerSource: source, + userAttributes: {}, + username: "username", + userPoolId: "userPoolId", + }); + + expect(mockLambdaClient.invoke).toHaveBeenCalledWith({ + FunctionName: "MyLambdaName", + InvocationType: "RequestResponse", + Payload: JSON.stringify({ + version: 0, + userName: "username", + callerContext: { awsSdkVersion: "2.656.0", clientId: "clientId" }, + region: "local", + userPoolId: "userPoolId", + triggerSource: source, + request: { + userAttributes: {}, + usernameParameter: "username", + codeParameter: "1234", + }, + response: {}, + }), + }); }); }); }); diff --git a/src/services/lambda.ts b/src/services/lambda.ts index fa03f0a1..640c1131 100644 --- a/src/services/lambda.ts +++ b/src/services/lambda.ts @@ -5,6 +5,22 @@ import { UnexpectedLambdaExceptionError } from "../errors"; import { version as awsSdkVersion } from "aws-sdk/package.json"; import { Logger } from "../log"; +interface CustomMessageEvent { + userPoolId: string; + clientId: string; + username: string; + code: string; + userAttributes: Record; + triggerSource: + | "CustomMessage_SignUp" + | "CustomMessage_AdminCreateUser" + | "CustomMessage_ResendCode" + | "CustomMessage_ForgotPassword" + | "CustomMessage_UpdateUserAttribute" + | "CustomMessage_VerifyUserAttribute" + | "CustomMessage_Authentication"; +} + interface UserMigrationEvent { userPoolId: string; clientId: string; @@ -27,6 +43,7 @@ interface PostConfirmationEvent { export type CognitoUserPoolResponse = CognitoUserPoolEvent["response"]; export interface FunctionConfig { + CustomMessage?: string; UserMigration?: string; PostConfirmation?: string; } @@ -34,8 +51,16 @@ export interface FunctionConfig { export interface Lambda { enabled(lambda: keyof FunctionConfig): boolean; invoke( - trigger: keyof FunctionConfig, - event: UserMigrationEvent | PostConfirmationEvent + lambda: "CustomMessage", + event: CustomMessageEvent + ): Promise; + invoke( + lambda: "UserMigration", + event: UserMigrationEvent + ): Promise; + invoke( + lambda: "PostConfirmation", + event: PostConfirmationEvent ): Promise; } @@ -60,8 +85,8 @@ export class LambdaService implements Lambda { public async invoke( trigger: keyof FunctionConfig, - event: UserMigrationEvent | PostConfirmationEvent - ): Promise { + event: CustomMessageEvent | UserMigrationEvent | PostConfirmationEvent + ) { const functionName = this.config[trigger]; if (!functionName) { throw new Error(`${trigger} trigger not configured`); @@ -86,6 +111,17 @@ export class LambdaService implements Lambda { if (event.triggerSource === "UserMigration_Authentication") { lambdaEvent.request.password = event.password; lambdaEvent.request.validationData = {}; + } else if ( + event.triggerSource === "CustomMessage_SignUp" || + event.triggerSource === "CustomMessage_AdminCreateUser" || + event.triggerSource === "CustomMessage_ResendCode" || + event.triggerSource === "CustomMessage_ForgotPassword" || + event.triggerSource === "CustomMessage_UpdateUserAttribute" || + event.triggerSource === "CustomMessage_VerifyUserAttribute" || + event.triggerSource === "CustomMessage_Authentication" + ) { + lambdaEvent.request.usernameParameter = event.username; + lambdaEvent.request.codeParameter = event.code; } this.logger.debug( diff --git a/src/services/messageDelivery/consoleMessageSender.test.ts b/src/services/messageDelivery/consoleMessageSender.test.ts new file mode 100644 index 00000000..f7f9511a --- /dev/null +++ b/src/services/messageDelivery/consoleMessageSender.test.ts @@ -0,0 +1,54 @@ +import { MockLogger } from "../../__tests__/mockLogger"; +import { ConsoleMessageSender } from "./consoleMessageSender"; +import * as TDB from "../../__tests__/testDataBuilder"; + +describe("consoleMessageSender", () => { + const user = TDB.user(); + const destination = "example@example.com"; + const mockLog = MockLogger; + const sender = new ConsoleMessageSender(mockLog); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe.each(["sendEmail", "sendSms"] as const)("%s", (fn) => { + it("prints the message to the console", async () => { + await sender[fn](user, destination, { + __code: "1234", + }); + + expect(mockLog.info).toHaveBeenCalledWith( + expect.stringMatching(/Username:\s+Username/) + ); + expect(mockLog.info).toHaveBeenCalledWith( + expect.stringMatching(/Destination:\s+example@example.com/) + ); + expect(mockLog.info).toHaveBeenCalledWith( + expect.stringMatching(/Code:\s+1234/) + ); + }); + + it("doesn't print undefined fields", async () => { + await sender[fn](user, destination, { + __code: "1234", + emailMessage: undefined, + }); + + expect(mockLog.info).not.toHaveBeenCalledWith( + expect.stringMatching(/Email Message/) + ); + }); + + it("prints additional fields", async () => { + await sender[fn](user, destination, { + __code: "1234", + emailMessage: "this is the email message", + }); + + expect(mockLog.info).toHaveBeenCalledWith( + expect.stringMatching(/Email Message:\s+this is the email message/) + ); + }); + }); +}); diff --git a/src/services/messages.test.ts b/src/services/messages.test.ts new file mode 100644 index 00000000..c0ac3f84 --- /dev/null +++ b/src/services/messages.test.ts @@ -0,0 +1,85 @@ +import { MessagesService } from "./messages"; +import { Triggers } from "./triggers"; +import * as TDB from "../__tests__/testDataBuilder"; + +describe("messages service", () => { + const mockTriggers: jest.Mocked = { + customMessage: jest.fn(), + enabled: jest.fn(), + postConfirmation: jest.fn(), + userMigration: jest.fn(), + }; + + const user = TDB.user(); + + it.todo("authentication"); + + describe("forgotPassword", () => { + describe("CustomMessage lambda is configured", () => { + describe("lambda returns a custom message", () => { + it("returns the custom message and code", async () => { + mockTriggers.enabled.mockReturnValue(true); + mockTriggers.customMessage.mockResolvedValue({ + smsMessage: "sms", + emailSubject: "email subject", + emailMessage: "email", + }); + + const messages = new MessagesService(mockTriggers); + const message = await messages.forgotPassword( + "clientId", + "userPoolId", + user, + "1234" + ); + + expect(message).toMatchObject({ + __code: "1234", + smsMessage: "sms", + emailSubject: "email subject", + emailMessage: "email", + }); + }); + }); + + describe("lambda does not return a custom message", () => { + it("returns just the code", async () => { + mockTriggers.enabled.mockReturnValue(true); + mockTriggers.customMessage.mockResolvedValue(null); + + const messages = new MessagesService(mockTriggers); + const message = await messages.forgotPassword( + "clientId", + "userPoolId", + user, + "1234" + ); + + expect(message).toMatchObject({ + __code: "1234", + }); + }); + }); + }); + + describe("CustomMessage lambda is not configured", () => { + it("returns just the code", async () => { + mockTriggers.enabled.mockReturnValue(false); + + const messages = new MessagesService(mockTriggers); + const message = await messages.forgotPassword( + "clientId", + "userPoolId", + user, + "1234" + ); + + expect(message).toMatchObject({ + __code: "1234", + }); + }); + }); + }); + + it.todo("signUp"); +}); diff --git a/src/services/messages.ts b/src/services/messages.ts index cb595cb9..b2e39ae8 100644 --- a/src/services/messages.ts +++ b/src/services/messages.ts @@ -1,3 +1,6 @@ +import { Triggers } from "./triggers"; +import { User } from "./userPoolClient"; + export interface Message { __code?: string; // not really part of the message, but we pass it around for convenience logging to the console emailMessage?: string; @@ -7,7 +10,12 @@ export interface Message { export interface Messages { authentication(code: string): Promise; - forgotPassword(code: string): Promise; + forgotPassword( + clientId: string, + userPoolId: string, + user: User, + code: string + ): Promise; signUp(code: string): Promise; } @@ -17,12 +25,42 @@ const stubMessage = (code: string) => }); export class MessagesService implements Messages { + private readonly triggers: Triggers; + + public constructor(triggers: Triggers) { + this.triggers = triggers; + } + public authentication(code: string): Promise { return stubMessage(code); } - public async forgotPassword(code: string): Promise { - return stubMessage(code); + public async forgotPassword( + clientId: string, + userPoolId: string, + user: User, + code: string + ): Promise { + if (this.triggers.enabled("PostConfirmation")) { + const message = await this.triggers.customMessage({ + clientId, + code, + source: "CustomMessage_ForgotPassword", + userAttributes: user.Attributes, + username: user.Username, + userPoolId, + }); + + return { + __code: code, + ...message, + }; + } + + // TODO: What should the default message be? + return { + __code: code, + }; } public signUp(code: string): Promise { diff --git a/src/services/triggers/customMessage.test.ts b/src/services/triggers/customMessage.test.ts new file mode 100644 index 00000000..0e954ce8 --- /dev/null +++ b/src/services/triggers/customMessage.test.ts @@ -0,0 +1,90 @@ +import { MockLogger } from "../../__tests__/mockLogger"; +import { CognitoClient } from "../cognitoClient"; +import { Lambda } from "../lambda"; +import { UserPoolClient } from "../userPoolClient"; +import { CustomMessage, CustomMessageTrigger } from "./customMessage"; + +describe("CustomMessage trigger", () => { + let mockLambda: jest.Mocked; + let mockCognitoClient: jest.Mocked; + let mockUserPoolClient: jest.Mocked; + let customMessage: CustomMessageTrigger; + + beforeEach(() => { + mockLambda = { + enabled: jest.fn(), + invoke: jest.fn(), + }; + mockUserPoolClient = { + config: { + Id: "test", + }, + createAppClient: jest.fn(), + getUserByUsername: jest.fn(), + listUsers: jest.fn(), + saveUser: jest.fn(), + }; + mockCognitoClient = { + getUserPool: jest.fn().mockResolvedValue(mockUserPoolClient), + getUserPoolForClientId: jest.fn().mockResolvedValue(mockUserPoolClient), + }; + + customMessage = CustomMessage( + { + lambda: mockLambda, + cognitoClient: mockCognitoClient, + }, + MockLogger + ); + }); + + describe("when lambda invoke fails", () => { + it("returns null", async () => { + mockLambda.invoke.mockRejectedValue(new Error("Something bad happened")); + + const message = await customMessage({ + clientId: "clientId", + code: "1234", + source: "CustomMessage_ForgotPassword", + userAttributes: [], + username: "username", + userPoolId: "userPoolId", + }); + + expect(message).toBeNull(); + }); + }); + + describe("when lambda invoke succeeds", () => { + it("saves user with attributes from response", async () => { + mockLambda.invoke.mockResolvedValue({ + emailMessage: "email message", + emailSubject: "email subject", + smsMessage: "sms message", + }); + + const message = await customMessage({ + clientId: "clientId", + code: "1234", + source: "CustomMessage_ForgotPassword", + userAttributes: [], + username: "example@example.com", + userPoolId: "userPoolId", + }); + + expect(mockLambda.invoke).toHaveBeenCalledWith("CustomMessage", { + clientId: "clientId", + code: "1234", + triggerSource: "CustomMessage_ForgotPassword", + userAttributes: {}, + username: "example@example.com", + userPoolId: "userPoolId", + }); + + expect(message).not.toBeNull(); + expect(message?.emailMessage).toEqual("email message"); + expect(message?.emailSubject).toEqual("email subject"); + expect(message?.smsMessage).toEqual("sms message"); + }); + }); +}); diff --git a/src/services/triggers/customMessage.ts b/src/services/triggers/customMessage.ts new file mode 100644 index 00000000..ddfac38e --- /dev/null +++ b/src/services/triggers/customMessage.ts @@ -0,0 +1,70 @@ +import { Logger } from "../../log"; +import { CognitoClient } from "../index"; +import { Lambda } from "../lambda"; +import { attributesToRecord } from "../userPoolClient"; +import { ResourceNotFoundError } from "../../errors"; + +interface CustomMessageResponse { + emailMessage?: string; + emailSubject?: string; + smsMessage?: string; +} + +export type CustomMessageTrigger = (params: { + source: + | "CustomMessage_SignUp" + | "CustomMessage_AdminCreateUser" + | "CustomMessage_ResendCode" + | "CustomMessage_ForgotPassword" + | "CustomMessage_UpdateUserAttribute" + | "CustomMessage_VerifyUserAttribute" + | "CustomMessage_Authentication"; + userPoolId: string; + clientId: string; + username: string; + code: string; + userAttributes: readonly { Name: string; Value: string }[]; +}) => Promise; + +export const CustomMessage = ( + { + lambda, + cognitoClient, + }: { + lambda: Lambda; + cognitoClient: CognitoClient; + }, + logger: Logger +): CustomMessageTrigger => async ({ + clientId, + code, + source, + userAttributes, + username, + userPoolId, +}): Promise => { + const userPool = await cognitoClient.getUserPoolForClientId(clientId); + if (!userPool) { + throw new ResourceNotFoundError(); + } + + try { + const response = await lambda.invoke("CustomMessage", { + clientId, + code, + triggerSource: source, + userAttributes: attributesToRecord(userAttributes), + username, + userPoolId, + }); + + return { + emailMessage: response.emailMessage, + emailSubject: response.emailSubject, + smsMessage: response.smsMessage, + }; + } catch (ex) { + logger.error(ex); + return null; + } +}; diff --git a/src/services/triggers/triggers.ts b/src/services/triggers/triggers.ts index 1a711d75..e129de99 100644 --- a/src/services/triggers/triggers.ts +++ b/src/services/triggers/triggers.ts @@ -1,11 +1,15 @@ import { Logger } from "../../log"; import { CognitoClient } from "../cognitoClient"; import { Lambda } from "../lambda"; +import { CustomMessage, CustomMessageTrigger } from "./customMessage"; import { PostConfirmation, PostConfirmationTrigger } from "./postConfirmation"; import { UserMigration, UserMigrationTrigger } from "./userMigration"; export interface Triggers { - enabled(trigger: "UserMigration" | "PostConfirmation"): boolean; + enabled( + trigger: "CustomMessage" | "UserMigration" | "PostConfirmation" + ): boolean; + customMessage: CustomMessageTrigger; userMigration: UserMigrationTrigger; postConfirmation: PostConfirmationTrigger; } @@ -13,6 +17,7 @@ export interface Triggers { export class TriggersService { private readonly lambda: Lambda; + public readonly customMessage: CustomMessageTrigger; public readonly userMigration: UserMigrationTrigger; public readonly postConfirmation: PostConfirmationTrigger; @@ -21,12 +26,15 @@ export class TriggersService { cognitoClient: CognitoClient, logger: Logger ) { + this.customMessage = CustomMessage({ lambda, cognitoClient }, logger); this.userMigration = UserMigration({ lambda, cognitoClient }); this.postConfirmation = PostConfirmation({ lambda, cognitoClient }, logger); this.lambda = lambda; } - public enabled(trigger: "UserMigration" | "PostConfirmation"): boolean { + public enabled( + trigger: "CustomMessage" | "UserMigration" | "PostConfirmation" + ): boolean { return this.lambda.enabled(trigger); } } diff --git a/src/targets/confirmForgotPassword.test.ts b/src/targets/confirmForgotPassword.test.ts index dc931149..73bdf421 100644 --- a/src/targets/confirmForgotPassword.test.ts +++ b/src/targets/confirmForgotPassword.test.ts @@ -32,6 +32,7 @@ describe("ConfirmForgotPassword target", () => { }; mockTriggers = { enabled: jest.fn(), + customMessage: jest.fn(), postConfirmation: jest.fn(), userMigration: jest.fn(), }; diff --git a/src/targets/confirmSignUp.test.ts b/src/targets/confirmSignUp.test.ts index 0f1475fb..ac9a8fc0 100644 --- a/src/targets/confirmSignUp.test.ts +++ b/src/targets/confirmSignUp.test.ts @@ -29,6 +29,7 @@ describe("ConfirmSignUp target", () => { }; mockTriggers = { enabled: jest.fn(), + customMessage: jest.fn(), postConfirmation: jest.fn(), userMigration: jest.fn(), }; diff --git a/src/targets/forgotPassword.ts b/src/targets/forgotPassword.ts index ecbbb474..e51449a3 100644 --- a/src/targets/forgotPassword.ts +++ b/src/targets/forgotPassword.ts @@ -37,7 +37,12 @@ export const ForgotPassword = ({ }; const code = otp(); - const message = await messages.forgotPassword(code); + const message = await messages.forgotPassword( + body.ClientId, + userPool.config.Id, + user, + code + ); await messageDelivery.deliver(user, deliveryDetails, message); await userPool.saveUser({ diff --git a/src/targets/initiateAuth.test.ts b/src/targets/initiateAuth.test.ts index 81eb4f5a..8771eabb 100644 --- a/src/targets/initiateAuth.test.ts +++ b/src/targets/initiateAuth.test.ts @@ -63,6 +63,7 @@ describe("InitiateAuth target", () => { mockOtp = jest.fn().mockReturnValue("1234"); mockTriggers = { enabled: jest.fn(), + customMessage: jest.fn(), postConfirmation: jest.fn(), userMigration: jest.fn(), }; diff --git a/src/targets/signUp.test.ts b/src/targets/signUp.test.ts index 7488941b..b0229eba 100644 --- a/src/targets/signUp.test.ts +++ b/src/targets/signUp.test.ts @@ -50,6 +50,7 @@ describe("SignUp target", () => { }; mockOtp = jest.fn(); mockTriggers = { + customMessage: jest.fn(), enabled: jest.fn(), postConfirmation: jest.fn(), userMigration: jest.fn(),