Skip to content

Commit

Permalink
[#IOPID-867] magic link on login email (#285)
Browse files Browse the repository at this point in the history
  • Loading branch information
arcogabbo authored Oct 16, 2023
1 parent 5c4edda commit 8b6e66f
Show file tree
Hide file tree
Showing 16 changed files with 801 additions and 70 deletions.
85 changes: 76 additions & 9 deletions GetMagicCodeActivity/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,101 @@
import { getActivityHandler } from "../handler";
import { ActivityResultSuccess, getActivityHandler } from "../handler";
import { context } from "../../__mocks__/durable-functions";
import { NonEmptyString } from "@pagopa/ts-commons/lib/strings";
import { aFiscalCode } from "../../__mocks__/mocks";
import { TransientNotImplementedFailure } from "../../utils/durable";
import * as E from "fp-ts/lib/Either";
import { MagicLinkServiceClient } from "../utils";

const aValidPayload = {
family_name: "foo" as NonEmptyString,
name: "foo" as NonEmptyString,
fiscal_code: aFiscalCode
};

const mockMagicCodeServiceClient = {
getMagicCodeForUser: jest.fn().mockRejectedValue({ status: 501 })
};
const aValidMagicLink = "https://example.com/#token=abcde" as NonEmptyString;

const getMagicLinkTokenMock = jest
.fn()
.mockResolvedValue(
E.right({ status: 200, value: { magic_link: aValidMagicLink } })
);

const mockMagicLinkServiceClient = ({
getMagicLinkToken: getMagicLinkTokenMock
} as unknown) as MagicLinkServiceClient;

describe("GetMagicCodeActivity", () => {
it("should return a NOT_YET_IMPLEMENTED failure", async () => {
const result = await getActivityHandler(mockMagicCodeServiceClient)(
it("should return a success with a valid input", async () => {
const result = await getActivityHandler(mockMagicLinkServiceClient)(
context as any,
aValidPayload
);

expect(getMagicLinkTokenMock).toHaveBeenCalledTimes(1);
expect(getMagicLinkTokenMock).toHaveBeenCalledWith({
body: {
family_name: aValidPayload.family_name,
fiscal_number: aValidPayload.fiscal_code,
name: aValidPayload.name
}
});
expect(ActivityResultSuccess.is(result)).toEqual(true);
});

it("should return a FAILURE when the service could not be reached via network", async () => {
const error = "an error";
getMagicLinkTokenMock.mockRejectedValueOnce(error);

const result = await getActivityHandler(mockMagicLinkServiceClient)(
context as any,
aValidPayload
);
expect(TransientNotImplementedFailure.is(result)).toEqual(true);

expect(ActivityResultSuccess.is(result)).toEqual(false);
expect(result).toMatchObject({
kind: "FAILURE",
reason: `Error while calling magic link service: ${error}`
});
});

it("should return a FAILURE when the service gives an unexpected response", async () => {
getMagicLinkTokenMock.mockResolvedValueOnce(E.left([]));

const result = await getActivityHandler(mockMagicLinkServiceClient)(
context as any,
aValidPayload
);

expect(ActivityResultSuccess.is(result)).toEqual(false);
expect(result).toMatchObject({
kind: "FAILURE",
reason: expect.stringContaining(
"magic link service returned an unexpected response:"
)
});
});

it("should return a FAILURE when the service gives a status code different from 200", async () => {
getMagicLinkTokenMock.mockResolvedValueOnce(E.right({ status: 500 }));

const result = await getActivityHandler(mockMagicLinkServiceClient)(
context as any,
aValidPayload
);

expect(ActivityResultSuccess.is(result)).toEqual(false);
expect(result).toMatchObject({
kind: "FAILURE",
reason: "magic link service returned 500"
});
});

it("should return a FAILURE when the input is not valid", async () => {
const result = await getActivityHandler(mockMagicCodeServiceClient)(
const result = await getActivityHandler(mockMagicLinkServiceClient)(
context as any,
{}
);

expect(ActivityResultSuccess.is(result)).toEqual(false);
expect(result).toMatchObject({
kind: "FAILURE",
reason: "Error while decoding input"
Expand Down
53 changes: 42 additions & 11 deletions GetMagicCodeActivity/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { FiscalCode, NonEmptyString } from "@pagopa/ts-commons/lib/strings";
import * as t from "io-ts";
import * as E from "fp-ts/lib/Either";
import * as TE from "fp-ts/lib/TaskEither";
import { pipe } from "fp-ts/function";
import { flow, pipe } from "fp-ts/function";
import { readableReportSimplified } from "@pagopa/ts-commons/lib/reporters";
import { TransientNotImplementedFailure } from "../utils/durable";
import { MagicLinkServiceClient } from "./utils";

// magic link service response
const MagicLinkServiceResponse = t.interface({
magic_code: NonEmptyString
magic_link: NonEmptyString
});

type MagicLinkServiceResponse = t.TypeOf<typeof MagicLinkServiceResponse>;
Expand Down Expand Up @@ -52,7 +52,7 @@ export type ActivityResult = t.TypeOf<typeof ActivityResult>;
const logPrefix = "GetMagicCodeActivity";

export const getActivityHandler = (
_magicCodeService: MagicLinkServiceClient
magicLinkService: MagicLinkServiceClient
) => async (context: Context, input: unknown): Promise<ActivityResult> =>
pipe(
input,
Expand All @@ -70,14 +70,45 @@ export const getActivityHandler = (
});
}),
TE.fromEither,
// TODO: implement the actual call to magic link service to get a
// magicCode
TE.chain(_activityInput =>
TE.left(
ActivityResultFailure.encode({
kind: "NOT_YET_IMPLEMENTED",
reason: "call not yet implemented"
})
TE.chain(({ name, family_name, fiscal_code }) =>
pipe(
TE.tryCatch(
() =>
magicLinkService.getMagicLinkToken({
body: { family_name, fiscal_number: fiscal_code, name }
}),
error => {
context.log.error(
`${logPrefix}|Error while calling magic link service|ERROR=${error}`
);
return ActivityResultFailure.encode({
kind: "FAILURE",
reason: `Error while calling magic link service: ${error}`
});
}
),
TE.chainEitherKW(
flow(
E.mapLeft(errors =>
ActivityResultFailure.encode({
kind: "FAILURE",
reason: `magic link service returned an unexpected response: ${readableReportSimplified(
errors
)}`
})
)
)
),
TE.chain(({ status, value }) =>
status === 200
? TE.right(value)
: TE.left(
ActivityResultFailure.encode({
kind: "FAILURE",
reason: `magic link service returned ${status}`
})
)
)
)
),
TE.map(serviceResponse =>
Expand Down
20 changes: 18 additions & 2 deletions GetMagicCodeActivity/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import { Millisecond } from "@pagopa/ts-commons/lib/units";
import { initTelemetryClient } from "../utils/appinsights";
import { magicLinkServiceClient } from "./utils";
import { getConfigOrThrow } from "../utils/config";
import { getTimeoutFetch } from "../utils/fetch";
import { getMagicLinkServiceClient } from "./utils";
import { getActivityHandler } from "./handler";

// HTTP external requests timeout in milliseconds
const REQUEST_TIMEOUT_MS = 5000;

const config = getConfigOrThrow();

const timeoutFetch = getTimeoutFetch(REQUEST_TIMEOUT_MS as Millisecond);

// Initialize application insights
initTelemetryClient();

const activityFunctionHandler = getActivityHandler(magicLinkServiceClient);
const activityFunctionHandler = getActivityHandler(
getMagicLinkServiceClient(
config.MAGIC_LINK_SERVICE_PUBLIC_URL,
config.MAGIC_LINK_SERVICE_API_KEY,
timeoutFetch
)
);

export default activityFunctionHandler;
35 changes: 25 additions & 10 deletions GetMagicCodeActivity/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import { FiscalCode, NonEmptyString } from "@pagopa/ts-commons/lib/strings";
import { NonEmptyString } from "@pagopa/ts-commons/lib/strings";
import nodeFetch from "node-fetch";
import {
Client,
createClient
} from "../generated/definitions/ioweb-function/client";

// TODO: instanciate an actual magicLinkServiceClient
export const magicLinkServiceClient = {
getMagicCodeForUser: (
fc: FiscalCode,
n: NonEmptyString,
f: NonEmptyString
): Promise<never> => Promise.reject({ status: 501, value: { f, fc, n } })
};
export const getMagicLinkServiceClient = (
magicLinkServiceBaseUrl: NonEmptyString,
token: NonEmptyString,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetchApi: typeof fetch = (nodeFetch as unknown) as typeof fetch
): Client<"ApiKeyAuth"> =>
createClient({
baseUrl: magicLinkServiceBaseUrl,
fetchApi,
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
withDefaults: op => params =>
op({
...params,
ApiKeyAuth: token
})
});

export type MagicLinkServiceClient = typeof magicLinkServiceClient;
export type MagicLinkServiceClient = ReturnType<
typeof getMagicLinkServiceClient
>;
4 changes: 2 additions & 2 deletions NoticeLoginEmailOrchestrator/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe("NoticeLoginEmailOrchestratorHandler", () => {

mockCallActivityFunction.mockReturnValueOnce({
kind: "SUCCESS",
value: { magic_code: "dummy" as NonEmptyString }
value: { magic_link: "dummy" as NonEmptyString }
});

mockCallActivityFunction.mockReturnValueOnce({
Expand Down Expand Up @@ -97,7 +97,7 @@ describe("NoticeLoginEmailOrchestratorHandler", () => {
date_time: aDate.getTime(),
name: "foo",
ip_address: anIPAddress,
magic_code: "dummy",
magic_link: "dummy",
identity_provider: "idp",
geo_location: "Rome",
email: "[email protected]",
Expand Down
6 changes: 3 additions & 3 deletions NoticeLoginEmailOrchestrator/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export const getNoticeLoginEmailOrchestratorHandler = function*(
});

// eslint-disable-next-line functional/no-let, @typescript-eslint/naming-convention
let magic_code: NonEmptyString | undefined;
let magic_link: NonEmptyString | undefined;
try {
const magicCodeActivityResult = yield context.df.callActivityWithRetry(
"GetMagicCodeActivity",
Expand All @@ -169,7 +169,7 @@ export const getNoticeLoginEmailOrchestratorHandler = function*(
);
if (E.isRight(errorOrMagicLinkServiceResponse)) {
if (errorOrMagicLinkServiceResponse.right.kind === "SUCCESS") {
magic_code = errorOrMagicLinkServiceResponse.right.value.magic_code;
magic_link = errorOrMagicLinkServiceResponse.right.value.magic_link;
} else {
context.log.error(
`${logPrefix}|GetMagicCodeActivity failed with ${errorOrMagicLinkServiceResponse.right.reason}`
Expand All @@ -191,7 +191,7 @@ export const getNoticeLoginEmailOrchestratorHandler = function*(
geo_location,
identity_provider,
ip_address,
magic_code,
magic_link,
name
}
);
Expand Down
37 changes: 28 additions & 9 deletions SendTemplatedLoginEmailActivity/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { getSendLoginEmailActivityHandler } from "../handler";
import { ActivityInput, getSendLoginEmailActivityHandler } from "../handler";
import { context } from "../../__mocks__/durable-functions";
import { EmailString, NonEmptyString } from "@pagopa/ts-commons/lib/strings";
import {
EmailString,
IPString,
NonEmptyString
} from "@pagopa/ts-commons/lib/strings";
import { EmailDefaults } from "../index";
import * as ai from "applicationinsights";
import * as mailTemplate from "../../generated/templates/login/index";
import * as fallbackMailTemplate from "../../generated/templates/login-fallback/index";

const aDate = new Date("1970-01-01");
const aValidPayload = {
const aValidPayload: ActivityInput = {
date_time: aDate,
name: "foo" as NonEmptyString,
email: "[email protected]" as EmailString,
identity_provider: "idp" as NonEmptyString,
ip_address: "127.0.0.1" as NonEmptyString
ip_address: "127.0.0.1" as IPString
};
const aValidPayloadWithMagicLink: ActivityInput = {
...aValidPayload,
magic_link: "http://example.com/#token=abcde" as NonEmptyString
};
const emailDefaults: EmailDefaults = {
from: "[email protected]" as any,
Expand All @@ -24,30 +34,40 @@ const mockMailerTransporter = {
})
};

const aPublicUrl = "https://localhost/" as NonEmptyString;
const aHelpDeskRef = "[email protected]" as NonEmptyString;

const mockTrackEvent = jest.fn();
const mockTracker = ({
trackEvent: mockTrackEvent
} as unknown) as ai.TelemetryClient;

const templateFunction = jest.spyOn(mailTemplate, "apply");
const fallbackTemplateFunction = jest.spyOn(fallbackMailTemplate, "apply");

describe("SendTemplatedLoginEmailActivity", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("should send a login email with the data", async () => {
it.each`
title | payload
${"fallback login email"} | ${aValidPayload}
${"login email"} | ${aValidPayloadWithMagicLink}
`("should send a $title with the data", async ({ payload }) => {
const handler = getSendLoginEmailActivityHandler(
mockMailerTransporter as any,
emailDefaults,
aPublicUrl,
aHelpDeskRef,
mockTracker
);

const result = await handler(context as any, aValidPayload);
const result = await handler(context as any, payload);

expect(result.kind).toEqual("SUCCESS");
expect(templateFunction).toHaveBeenCalledTimes(payload.magic_link ? 1 : 0);
expect(fallbackTemplateFunction).toHaveBeenCalledTimes(
payload.magic_link ? 0 : 1
);
expect(mockMailerTransporter.sendMail).toHaveBeenCalledTimes(1);
expect(mockMailerTransporter.sendMail).toHaveBeenCalledWith(
{
Expand All @@ -66,7 +86,6 @@ describe("SendTemplatedLoginEmailActivity", () => {
const handler = getSendLoginEmailActivityHandler(
mockMailerTransporter as any,
emailDefaults,
aPublicUrl,
aHelpDeskRef,
mockTracker
);
Expand Down
Loading

0 comments on commit 8b6e66f

Please sign in to comment.