diff --git a/WebhookNotification/__tests__/handler.test.ts b/WebhookNotification/__tests__/handler.test.ts index c8937855..41b44a26 100644 --- a/WebhookNotification/__tests__/handler.test.ts +++ b/WebhookNotification/__tests__/handler.test.ts @@ -15,6 +15,7 @@ import { import { CreatedMessageEventSenderMetadata } from "@pagopa/io-functions-commons/dist/src/models/created_message_sender_metadata"; import { Notification } from "@pagopa/io-functions-commons/dist/src/models/notification"; import { isTransientError } from "@pagopa/io-functions-commons/dist/src/utils/errors"; +import { PushNotificationsContentTypeEnum } from "@pagopa/io-functions-commons/dist/generated/definitions/PushNotificationsContentType"; import { getWebhookNotificationHandler, sendToWebhook } from "../handler"; @@ -38,6 +39,11 @@ import * as TE from "fp-ts/lib/TaskEither"; import * as O from "fp-ts/lib/Option"; import { StandardServiceCategoryEnum } from "../../generated/api-admin/StandardServiceCategory"; +import { toInternalError, toNotFoundError } from "../../utils/domain-errors"; +import { UserProfileReader } from "../../readers/user-profile"; + +import { aRetrievedProfile } from "../../__mocks__/mocks"; + const mockAppinsights = { trackDependency: jest.fn(), trackEvent: jest.fn() @@ -126,16 +132,25 @@ const aCommonMessageData = { senderMetadata: aSenderMetadata }; +const aRetrievedProfileWithFullPushNotificationsContentType = { + ...aRetrievedProfile, + pushNotificationsContentType: PushNotificationsContentTypeEnum.FULL +}; + const mockRetrieveProcessingMessageData = jest .fn() .mockImplementation(() => TE.of(O.some(aCommonMessageData))); +const userProfileReaderMock = jest.fn( + _ => TE.of(aRetrievedProfile) as ReturnType +); + beforeEach(() => { jest.clearAllMocks(); }); describe("sendToWebhook", () => { - it("should succeded with right parameters", async () => { + it("should succeded with right parameters, with message content", async () => { const expectedResponse = { message: "OK" }; const fetchApi = mockFetch(200, expectedResponse); const notifyApiCall = getNotifyClient(fetchApi as any); @@ -145,8 +160,15 @@ describe("sendToWebhook", () => { aMessage as any, aMessageContent, aSenderMetadata, + aRetrievedProfileWithFullPushNotificationsContentType, false )(); + + expect(fetchApi.mock.calls[0][1]).toHaveProperty("body"); + const body = JSON.parse(fetchApi.mock.calls[0][1].body); + expect(body).toHaveProperty("message"); + expect(body).toHaveProperty("sender_metadata"); + expect(body.message).toHaveProperty("content"); expect(E.isRight(ret)).toBeTruthy(); }); @@ -163,15 +185,17 @@ describe("sendToWebhook", () => { ...aSenderMetadata, requireSecureChannels: true }, + aRetrievedProfileWithFullPushNotificationsContentType, false )(); expect(fetchApi.mock.calls[0][1]).toHaveProperty("body"); const body = JSON.parse(fetchApi.mock.calls[0][1].body); expect(body).toHaveProperty("message"); expect(body).toHaveProperty("sender_metadata"); - expect(body).not.toHaveProperty("content"); + expect(body.message).not.toHaveProperty("content"); expect(E.isRight(ret)).toBeTruthy(); }); + it("should remove message content if webhook message content is disabled", async () => { const expectedResponse = { message: "OK" }; const fetchApi = mockFetch(200, expectedResponse); @@ -182,15 +206,38 @@ describe("sendToWebhook", () => { aMessage as any, aMessageContent, aSenderMetadata, + aRetrievedProfileWithFullPushNotificationsContentType, true )(); expect(fetchApi.mock.calls[0][1]).toHaveProperty("body"); const body = JSON.parse(fetchApi.mock.calls[0][1].body); expect(body).toHaveProperty("message"); expect(body).toHaveProperty("sender_metadata"); - expect(body).not.toHaveProperty("content"); + expect(body.message).not.toHaveProperty("content"); + expect(E.isRight(ret)).toBeTruthy(); + }); + + it("should remove message content if user did not allow verbose notifications", async () => { + const expectedResponse = { message: "OK" }; + const fetchApi = mockFetch(200, expectedResponse); + const notifyApiCall = getNotifyClient(fetchApi as any); + const ret = await sendToWebhook( + notifyApiCall, + "http://localhost/test" as HttpsUrl, + aMessage as any, + aMessageContent, + aSenderMetadata, + aRetrievedProfile, + false + )(); + expect(fetchApi.mock.calls[0][1]).toHaveProperty("body"); + const body = JSON.parse(fetchApi.mock.calls[0][1].body); + expect(body).toHaveProperty("message"); + expect(body).toHaveProperty("sender_metadata"); + expect(body.message).not.toHaveProperty("content"); expect(E.isRight(ret)).toBeTruthy(); }); + it("should return a transient error in case of timeout", async () => { const abortableFetch = AbortableFetch(agent.getHttpsFetch(process.env)); const fetchWithTimeout = setFetchTimeout(1 as Millisecond, abortableFetch); @@ -201,6 +248,7 @@ describe("sendToWebhook", () => { aMessage as any, aMessageContent, aSenderMetadata, + aRetrievedProfile, false )(); expect(E.isLeft(ret)).toBeTruthy(); @@ -218,6 +266,7 @@ describe("sendToWebhook", () => { {} as any, {} as any, {} as any, + aRetrievedProfile, false )(); expect(fetchApi).toHaveBeenCalledTimes(1); @@ -236,6 +285,7 @@ describe("sendToWebhook", () => { {} as any, {} as any, {} as any, + aRetrievedProfile, false )(); expect(fetchApi).toHaveBeenCalledTimes(1); @@ -260,6 +310,7 @@ describe("handler", () => { notificationModelMock as any, {} as any, mockRetrieveProcessingMessageData, + userProfileReaderMock, false )(mockContext, JSON.stringify(aNotificationEvent)) ).rejects.toThrow(); @@ -275,6 +326,76 @@ describe("handler", () => { notificationModelMock as any, {} as any, mockRetrieveProcessingMessageData, + userProfileReaderMock, + false + )(mockContext, JSON.stringify(aNotificationEvent)) + ).rejects.toThrow(); + }); + + it("should return a transient error when an error occurred retrieving user profile", async () => { + const notificationModelMock = { + find: jest.fn(() => TE.of(O.some(aNotification))), + update: jest.fn(() => TE.of(O.some(aNotification))) + }; + + const notifyCallApiMock = jest + .fn() + .mockReturnValue(Promise.resolve(E.right({ status: 200 }))); + + mockRetrieveProcessingMessageData.mockImplementationOnce(() => + TE.of(O.some({ ...aCommonMessageData, content: aMessageContent })) + ); + + userProfileReaderMock.mockImplementationOnce(() => + TE.left( + toInternalError( + "an Error" as NonEmptyString, + "an Error detail" as NonEmptyString + ) + ) + ); + + await expect( + getWebhookNotificationHandler( + notificationModelMock as any, + notifyCallApiMock as any, + mockRetrieveProcessingMessageData, + userProfileReaderMock, + false + )(mockContext, JSON.stringify(aNotificationEvent)) + ).rejects.toThrow(); + }); + + it("should return a transient error when user profile has not been found", async () => { + const notificationModelMock = { + find: jest.fn(() => TE.of(O.some(aNotification))), + update: jest.fn(() => TE.of(O.some(aNotification))) + }; + + const notifyCallApiMock = jest + .fn() + .mockReturnValue(Promise.resolve(E.right({ status: 200 }))); + + mockRetrieveProcessingMessageData.mockImplementationOnce(() => + TE.of(O.some({ ...aCommonMessageData, content: aMessageContent })) + ); + + userProfileReaderMock.mockImplementationOnce(() => + TE.left( + toNotFoundError( + "an Error" as NonEmptyString, + "an Error detail" as NonEmptyString, + "profile" as NonEmptyString + ) + ) + ); + + await expect( + getWebhookNotificationHandler( + notificationModelMock as any, + notifyCallApiMock as any, + mockRetrieveProcessingMessageData, + userProfileReaderMock, false )(mockContext, JSON.stringify(aNotificationEvent)) ).rejects.toThrow(); @@ -290,6 +411,7 @@ describe("handler", () => { notificationModelMock as any, {} as any, mockRetrieveProcessingMessageData, + userProfileReaderMock, false )(mockContext, JSON.stringify(aNotificationEvent)) ).resolves.toEqual({ kind: "FAILURE", reason: "DECODE_ERROR" }); @@ -313,6 +435,7 @@ describe("handler", () => { notificationModelMock as any, notifyCallApiMock as any, mockRetrieveProcessingMessageData, + userProfileReaderMock, false )(mockContext, JSON.stringify(aNotificationEvent)); @@ -347,6 +470,7 @@ describe("handler", () => { notificationModelMock as any, notifyCallApiMock, mockRetrieveProcessingMessageData, + userProfileReaderMock, false )(mockContext, JSON.stringify(aNotificationEvent)); @@ -375,6 +499,7 @@ describe("handler", () => { notificationModelMock as any, notifyCallApiMock, mockRetrieveProcessingMessageData, + userProfileReaderMock, false )(mockContext, JSON.stringify(aNotificationEvent)) ).resolves.toEqual({ kind: "FAILURE", reason: "SEND_TO_WEBHOOK_FAILED" }); @@ -403,6 +528,7 @@ describe("handler", () => { notificationModelMock as any, {} as any, mockRetrieveProcessingMessageData, + userProfileReaderMock, false )(mockContext, JSON.stringify(aNotificationEvent)); diff --git a/WebhookNotification/handler.ts b/WebhookNotification/handler.ts index b9ad0499..ab4b4730 100644 --- a/WebhookNotification/handler.ts +++ b/WebhookNotification/handler.ts @@ -42,6 +42,8 @@ import { } from "@pagopa/ts-commons/lib/requests"; import { flow, pipe } from "fp-ts/lib/function"; import { TaskEither } from "fp-ts/lib/TaskEither"; +import { Profile } from "@pagopa/io-functions-commons/dist/src/models/profile"; +import { PushNotificationsContentTypeEnum } from "@pagopa/io-functions-commons/dist/generated/definitions/PushNotificationsContentType"; import { Notification } from "../generated/notifications/Notification"; import { withJsonInput } from "../utils/with-json-input"; import { withDecodedInput } from "../utils/with-decoded-input"; @@ -50,6 +52,7 @@ import { NotificationCreatedEvent } from "../utils/events/message"; import { DataFetcher, withExpandedInput } from "../utils/with-expanded-input"; +import { UserProfileReader } from "../readers/user-profile"; import { WebhookNotifyT } from "./client"; export const WebhookNotificationInput = NotificationCreatedEvent; @@ -116,6 +119,7 @@ export const sendToWebhook = ( message: NewMessageWithoutContent, content: MessageContent, senderMetadata: CreatedMessageEventSenderMetadata, + userProfile: Profile, disableWebhookMessageContent: boolean // eslint-disable-next-line max-params ): TaskEither> => @@ -125,11 +129,14 @@ export const sendToWebhook = ( notifyApiCall({ notification: { // If the service requires secure channels + // or user did not allow to receive verbose notifications // or the message content is disabled for all services // we send an empty (generic) push notification // generic content is provided by `io-backend` https://github.com/pagopa/io-backend/blob/v7.16.0/src/controllers/notificationController.ts#L62 message: senderMetadata.requireSecureChannels || + userProfile.pushNotificationsContentType !== + PushNotificationsContentTypeEnum.FULL || disableWebhookMessageContent ? newMessageToPublic(message) : newMessageToPublic(message, content), @@ -183,6 +190,7 @@ export const getWebhookNotificationHandler = ( lNotificationModel: NotificationModel, notifyApiCall: TypeofApiCall, retrieveProcessingMessageData: DataFetcher, + userProfileReader: UserProfileReader, disableWebhookMessageContent: boolean ) => withJsonInput( @@ -251,12 +259,22 @@ export const getWebhookNotificationHandler = ( const webhookNotification = errorOrWebhookNotification.right.channels.WEBHOOK; + const userProfile = await pipe( + userProfileReader({ + fiscalCode: message.fiscalCode + }), + TE.getOrElse(err => { + throw new Error(err.title); + }) + )(); + const sendResult = await sendToWebhook( notifyApiCall, webhookNotification.url, message, content, senderMetadata, + userProfile, disableWebhookMessageContent )(); if (E.isLeft(sendResult)) { diff --git a/WebhookNotification/index.ts b/WebhookNotification/index.ts index 75014950..7d7922cb 100644 --- a/WebhookNotification/index.ts +++ b/WebhookNotification/index.ts @@ -2,6 +2,10 @@ import { NOTIFICATION_COLLECTION_NAME, NotificationModel } from "@pagopa/io-functions-commons/dist/src/models/notification"; +import { + ProfileModel, + PROFILE_COLLECTION_NAME +} from "@pagopa/io-functions-commons/dist/src/models/profile"; import { agent } from "@pagopa/ts-commons"; @@ -12,6 +16,7 @@ import { } from "@pagopa/ts-commons/lib/fetch"; import { Millisecond } from "@pagopa/ts-commons/lib/units"; import { createBlobService } from "azure-storage"; +import { getUserProfileReader } from "../readers/user-profile"; import { getConfigOrThrow } from "../utils/config"; import { cosmosdbInstance } from "../utils/cosmosdb"; import { CommonMessageData } from "../utils/events/message"; @@ -47,9 +52,14 @@ const retrieveProcessingMessageData = makeRetrieveExpandedDataFromBlob( config.PROCESSING_MESSAGE_CONTAINER_NAME ); +const profileModel = new ProfileModel( + cosmosdbInstance.container(PROFILE_COLLECTION_NAME) +); + export default getWebhookNotificationHandler( notificationModel, notifyApiCall, retrieveProcessingMessageData, + getUserProfileReader(profileModel), config.FF_DISABLE_WEBHOOK_MESSAGE_CONTENT ); diff --git a/readers/user-profile.ts b/readers/user-profile.ts new file mode 100644 index 00000000..cac092dd --- /dev/null +++ b/readers/user-profile.ts @@ -0,0 +1,57 @@ +import { pipe } from "fp-ts/lib/function"; + +import * as TE from "fp-ts/TaskEither"; +import * as RTE from "fp-ts/ReaderTaskEither"; + +import { + Profile, + ProfileModel +} from "@pagopa/io-functions-commons/dist/src/models/profile"; + +import { FiscalCode, NonEmptyString } from "@pagopa/ts-commons/lib/strings"; +import { + cosmosErrorsToString, + InternalError, + NotFoundError, + toInternalError, + toNotFoundError +} from "../utils/domain-errors"; + +// ----------------------------------------- +// Interfaces +// ----------------------------------------- + +/** + * It returns either a valid Profile or an Error. + */ +export type UserProfileReader = RTE.ReaderTaskEither< + { readonly fiscalCode: FiscalCode }, + NotFoundError | InternalError, + Profile +>; + +// -------------------------------------------- +// Implementations +// -------------------------------------------- + +export const getUserProfileReader = ( + profileModel: ProfileModel +): UserProfileReader => ({ fiscalCode }): ReturnType => + pipe( + profileModel.findLastVersionByModelId([fiscalCode]), + TE.mapLeft(cosmosError => + toInternalError( + `Error while retrieving user profile from Cosmos DB` as NonEmptyString, + cosmosErrorsToString(cosmosError) + ) + ), + TE.chainW( + TE.fromOption(() => + toNotFoundError( + "User profile not found" as NonEmptyString, + `User profile was not found for the given Fiscal Code` as NonEmptyString, + "profile" as NonEmptyString + ) + ) + ) + ); diff --git a/utils/domain-errors.ts b/utils/domain-errors.ts new file mode 100644 index 00000000..bb6ee035 --- /dev/null +++ b/utils/domain-errors.ts @@ -0,0 +1,64 @@ +import { CosmosErrors } from "@pagopa/io-functions-commons/dist/src/utils/cosmosdb_model"; +import { errorsToReadableMessages } from "@pagopa/ts-commons/lib/reporters"; +import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; +import { pipe } from "fp-ts/lib/function"; +import * as t from "io-ts"; + +export const BaseError = t.type({ + detail: NonEmptyString, + title: NonEmptyString +}); + +enum ErrorKind { + NotFound = "NotFound", + Internal = "Internal" +} + +export type NotFoundError = t.TypeOf; +export const NotFoundError = t.intersection([ + BaseError, + t.type({ + kind: t.literal(ErrorKind.NotFound), + objectName: NonEmptyString + }) +]); + +export const toNotFoundError = ( + title: NonEmptyString, + detail: NonEmptyString, + objectName: NonEmptyString +): NotFoundError => ({ detail, kind: ErrorKind.NotFound, objectName, title }); + +export type InternalError = t.TypeOf; +export const InternalError = t.intersection([ + BaseError, + t.type({ + kind: t.literal(ErrorKind.Internal) + }) +]); + +export const toInternalError = ( + title: NonEmptyString, + detail: NonEmptyString +): InternalError => ({ detail, kind: ErrorKind.Internal, title }); + +/** + * All domain errors + */ +export type DomainErrors = t.TypeOf; +export const DomainErrors = t.union([InternalError, NotFoundError]); + +// ------------------------------------- +// utils +// ------------------------------------- + +export const cosmosErrorsToString = (errs: CosmosErrors): NonEmptyString => + pipe( + errs.kind === "COSMOS_EMPTY_RESPONSE" + ? "Empty response" + : errs.kind === "COSMOS_DECODING_ERROR" + ? "Decoding error: " + errorsToReadableMessages(errs.error).join("/") + : "Generic error: " + JSON.stringify(errs.error), + + errorString => errorString as NonEmptyString + );