diff --git a/GetLimitedProfile/__tests__/handler.test.ts b/GetLimitedProfile/__tests__/handler.test.ts new file mode 100644 index 00000000..d109e2a5 --- /dev/null +++ b/GetLimitedProfile/__tests__/handler.test.ts @@ -0,0 +1,183 @@ +import * as fc from "fast-check"; +import { left, right } from "fp-ts/lib/Either"; +import { none, some } from "fp-ts/lib/Option"; +import { BlockedInboxOrChannelEnum } from "io-functions-commons/dist/generated/definitions/BlockedInboxOrChannel"; +import { + IProfileBlockedInboxOrChannels, + ProfileModel +} from "io-functions-commons/dist/src/models/profile"; +import { IAzureApiAuthorization } from "io-functions-commons/dist/src/utils/middlewares/azure_api_auth"; +import { IAzureUserAttributes } from "io-functions-commons/dist/src/utils/middlewares/azure_user_attributes"; +import { EmailString, NonEmptyString } from "italia-ts-commons/lib/strings"; + +import { + clientIpArb, + fiscalCodeArb, + profileArb +} from "../../utils/arbitraries"; +import { + GetLimitedProfileHandler, + isSenderAllowed, + toLimitedProfile +} from "../handler"; + +describe("isSenderAllowed", () => { + it("should return false if the service is not allowed to send notifications to the user", () => { + const blockedInboxOrChannels: IProfileBlockedInboxOrChannels = { + "01234567890": new Set([BlockedInboxOrChannelEnum.INBOX]) + }; + + const isAllowed = isSenderAllowed( + blockedInboxOrChannels, + "01234567890" as NonEmptyString + ); + + expect(isAllowed).toBe(false); + }); + + it("should return true if the service is allowed to send notifications to the user", () => { + const blockedInboxOrChannels: IProfileBlockedInboxOrChannels = {}; + + const isAllowed = isSenderAllowed( + blockedInboxOrChannels, + "01234567890" as NonEmptyString + ); + + expect(isAllowed).toBe(true); + }); +}); + +describe("toLimitedProfile", () => { + it("should return a LimitedProfile with the right data", () => { + fc.assert( + fc.property(profileArb, fc.boolean(), (profile, senderAllowed) => { + const limitedProfile = toLimitedProfile(profile, senderAllowed); + expect(limitedProfile).toEqual({ + preferred_languages: profile.preferredLanguages, + sender_allowed: senderAllowed + }); + }) + ); + }); +}); + +describe("GetLimitedProfileHandler", () => { + const mockAzureApiAuthorization: IAzureApiAuthorization = { + groups: new Set(), + kind: "IAzureApiAuthorization", + subscriptionId: "" as NonEmptyString, + userId: "" as NonEmptyString + }; + + const mockAzureUserAttributes: IAzureUserAttributes = { + email: "" as EmailString, + kind: "IAzureUserAttributes", + service: { + serviceId: "01234567890" + } as IAzureUserAttributes["service"] + }; + + it("should respond with ResponseErrorQuery when a database error occurs", async () => { + await fc.assert( + fc.asyncProperty( + clientIpArb, + fiscalCodeArb, + async (clientIp, fiscalCode) => { + const mockProfileModel = ({ + findOneProfileByFiscalCode: jest.fn(() => Promise.resolve(left({}))) + } as unknown) as ProfileModel; + const limitedProfileHandler = GetLimitedProfileHandler( + mockProfileModel + ); + + const response = await limitedProfileHandler( + mockAzureApiAuthorization, + clientIp, + mockAzureUserAttributes, + fiscalCode + ); + + expect( + mockProfileModel.findOneProfileByFiscalCode + ).toHaveBeenCalledTimes(1); + expect(mockProfileModel.findOneProfileByFiscalCode).toBeCalledWith( + fiscalCode + ); + expect(response.kind).toBe("IResponseErrorQuery"); + } + ) + ); + }); + + it("should respond with ResponseErrorNotFound when the requested profile is not found in the db", async () => { + await fc.assert( + fc.asyncProperty( + clientIpArb, + fiscalCodeArb, + async (clientIp, fiscalCode) => { + const mockProfileModel = ({ + findOneProfileByFiscalCode: jest.fn(() => + Promise.resolve(right(none)) + ) + } as unknown) as ProfileModel; + const limitedProfileHandler = GetLimitedProfileHandler( + mockProfileModel + ); + + const response = await limitedProfileHandler( + mockAzureApiAuthorization, + clientIp, + mockAzureUserAttributes, + fiscalCode + ); + + expect( + mockProfileModel.findOneProfileByFiscalCode + ).toHaveBeenCalledTimes(1); + expect(mockProfileModel.findOneProfileByFiscalCode).toBeCalledWith( + fiscalCode + ); + expect(response.kind).toBe("IResponseErrorNotFound"); + } + ) + ); + }); + + it("should respond with ResponseSuccessJson when the requested profile is found in the db", async () => { + await fc.assert( + fc.asyncProperty( + clientIpArb, + fiscalCodeArb, + profileArb, + async (clientIp, fiscalCode, profile) => { + const mockProfileModel = ({ + findOneProfileByFiscalCode: jest.fn(() => + Promise.resolve(right(some(profile))) + ) + } as unknown) as ProfileModel; + const limitedProfileHandler = GetLimitedProfileHandler( + mockProfileModel + ); + + const response = await limitedProfileHandler( + mockAzureApiAuthorization, + clientIp, + mockAzureUserAttributes, + fiscalCode + ); + + expect( + mockProfileModel.findOneProfileByFiscalCode + ).toHaveBeenCalledTimes(1); + expect(mockProfileModel.findOneProfileByFiscalCode).toBeCalledWith( + fiscalCode + ); + expect(response.kind).toBe("IResponseSuccessJson"); + if (response.kind === "IResponseSuccessJson") { + expect(response.value).toEqual(toLimitedProfile(profile, false)); + } + } + ) + ); + }); +}); diff --git a/GetLimitedProfile/function.json b/GetLimitedProfile/function.json new file mode 100644 index 00000000..bf6a5d21 --- /dev/null +++ b/GetLimitedProfile/function.json @@ -0,0 +1,20 @@ +{ + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "route": "v1/profiles/{fiscalCode}", + "methods": [ + "get" + ] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ], + "scriptFile": "../dist/GetLimitedProfile/index.js" +} diff --git a/GetLimitedProfile/handler.ts b/GetLimitedProfile/handler.ts new file mode 100644 index 00000000..d2228a6c --- /dev/null +++ b/GetLimitedProfile/handler.ts @@ -0,0 +1,152 @@ +import * as express from "express"; +import { isRight } from "fp-ts/lib/Either"; +import { isSome } from "fp-ts/lib/Option"; +import { BlockedInboxOrChannelEnum } from "io-functions-commons/dist/generated/definitions/BlockedInboxOrChannel"; +import { LimitedProfile } from "io-functions-commons/dist/generated/definitions/LimitedProfile"; +import { ServiceId } from "io-functions-commons/dist/generated/definitions/ServiceId"; +import { + IProfileBlockedInboxOrChannels, + Profile, + ProfileModel +} from "io-functions-commons/dist/src/models/profile"; +import { ServiceModel } from "io-functions-commons/dist/src/models/service"; +import { + AzureApiAuthMiddleware, + IAzureApiAuthorization, + UserGroup +} from "io-functions-commons/dist/src/utils/middlewares/azure_api_auth"; +import { + AzureUserAttributesMiddleware, + IAzureUserAttributes +} from "io-functions-commons/dist/src/utils/middlewares/azure_user_attributes"; +import { + ClientIp, + ClientIpMiddleware +} from "io-functions-commons/dist/src/utils/middlewares/client_ip_middleware"; +import { FiscalCodeMiddleware } from "io-functions-commons/dist/src/utils/middlewares/fiscalcode"; +import { + withRequestMiddlewares, + wrapRequestHandler +} from "io-functions-commons/dist/src/utils/request_middleware"; +import { + IResponseErrorQuery, + ResponseErrorQuery +} from "io-functions-commons/dist/src/utils/response"; +import { + checkSourceIpForHandler, + clientIPAndCidrTuple as ipTuple +} from "io-functions-commons/dist/src/utils/source_ip_check"; +import { + IResponseErrorNotFound, + IResponseSuccessJson, + ResponseErrorNotFound, + ResponseSuccessJson +} from "italia-ts-commons/lib/responses"; +import { FiscalCode } from "italia-ts-commons/lib/strings"; + +/** + * Type of a GetLimitedProfile handler. + * + * GetLimitedProfile expects a FiscalCode as input and returns a LimitedProfile or a NotFound error. + */ +type IGetLimitedProfileHandler = ( + apiAuthorization: IAzureApiAuthorization, + clientIp: ClientIp, + userAttributes: IAzureUserAttributes, + fiscalCode: FiscalCode +) => Promise< + | IResponseSuccessJson + | IResponseErrorNotFound + | IResponseErrorQuery +>; + +/** + * Whether the sender service is allowed to send + * messages to the user identified by this profile + */ +export function isSenderAllowed( + blockedInboxOrChannels: IProfileBlockedInboxOrChannels | undefined, + serviceId: ServiceId +): boolean { + return ( + blockedInboxOrChannels === undefined || + blockedInboxOrChannels[serviceId] === undefined || + !blockedInboxOrChannels[serviceId].has(BlockedInboxOrChannelEnum.INBOX) + ); +} + +/** + * Converts the Profile model to LimitedProfile type. + */ +export function toLimitedProfile( + profile: Profile, + senderAllowed: boolean +): LimitedProfile { + return { + preferred_languages: profile.preferredLanguages, + // computed property + sender_allowed: senderAllowed + }; +} + +/** + * Returns a type safe GetLimitedProfile handler. + */ +export function GetLimitedProfileHandler( + profileModel: ProfileModel +): IGetLimitedProfileHandler { + return async (_, __, userAttributes, fiscalCode) => { + const maybeProfileOrError = await profileModel.findOneProfileByFiscalCode( + fiscalCode + ); + if (isRight(maybeProfileOrError)) { + const maybeProfile = maybeProfileOrError.value; + if (isSome(maybeProfile)) { + const profile = maybeProfile.value; + + return ResponseSuccessJson( + toLimitedProfile( + profile, + isSenderAllowed( + profile.blockedInboxOrChannels, + userAttributes.service.serviceId + ) + ) + ); + } else { + return ResponseErrorNotFound( + "Profile not found", + "The profile you requested was not found in the system." + ); + } + } else { + return ResponseErrorQuery( + "Error while retrieving the profile", + maybeProfileOrError.value + ); + } + }; +} + +/** + * Wraps a GetLimitedProfile handler inside an Express request handler. + */ +export function GetLimitedProfile( + serviceModel: ServiceModel, + profileModel: ProfileModel +): express.RequestHandler { + const handler = GetLimitedProfileHandler(profileModel); + + const middlewaresWrap = withRequestMiddlewares( + AzureApiAuthMiddleware(new Set([UserGroup.ApiLimitedProfileRead])), + ClientIpMiddleware, + AzureUserAttributesMiddleware(serviceModel), + FiscalCodeMiddleware + ); + + return wrapRequestHandler( + middlewaresWrap( + checkSourceIpForHandler(handler, (_, c, u, __) => ipTuple(c, u)) + ) + ); +} diff --git a/GetLimitedProfile/index.ts b/GetLimitedProfile/index.ts new file mode 100644 index 00000000..f93be210 --- /dev/null +++ b/GetLimitedProfile/index.ts @@ -0,0 +1,73 @@ +import { Context } from "@azure/functions"; +import cors = require("cors"); +import { DocumentClient as DocumentDBClient } from "documentdb"; +import express = require("express"); +import { + PROFILE_COLLECTION_NAME, + ProfileModel +} from "io-functions-commons/dist/src/models/profile"; +import { + SERVICE_COLLECTION_NAME, + ServiceModel +} from "io-functions-commons/dist/src/models/service"; +import * as documentDbUtils from "io-functions-commons/dist/src/utils/documentdb"; +import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; +import { secureExpressApp } from "io-functions-commons/dist/src/utils/express"; +import { AzureContextTransport } from "io-functions-commons/dist/src/utils/logging"; +import { setAppContext } from "io-functions-commons/dist/src/utils/middlewares/context_middleware"; +import createAzureFunctionHandler from "io-functions-express/dist/src/createAzureFunctionsHandler"; +import winston = require("winston"); + +import { GetLimitedProfile } from "./handler"; + +// Setup Express +const app = express(); +secureExpressApp(app); + +// Set up CORS (free access to the API from browser clients) +app.use(cors()); + +const cosmosDbUri = getRequiredStringEnv("CUSTOMCONNSTR_COSMOSDB_URI"); +const cosmosDbKey = getRequiredStringEnv("CUSTOMCONNSTR_COSMOSDB_KEY"); +const cosmosDbName = getRequiredStringEnv("COSMOSDB_NAME"); + +const documentClient = new DocumentDBClient(cosmosDbUri, { + masterKey: cosmosDbKey +}); + +const documentDbDatabaseUrl = documentDbUtils.getDatabaseUri(cosmosDbName); + +const servicesCollectionUrl = documentDbUtils.getCollectionUri( + documentDbDatabaseUrl, + SERVICE_COLLECTION_NAME +); +const serviceModel = new ServiceModel(documentClient, servicesCollectionUrl); + +const profilesCollectionUrl = documentDbUtils.getCollectionUri( + documentDbDatabaseUrl, + PROFILE_COLLECTION_NAME +); +const profileModel = new ProfileModel(documentClient, profilesCollectionUrl); + +app.get( + "/api/v1/profiles/:fiscalcode", + GetLimitedProfile(serviceModel, profileModel) +); + +// tslint:disable-next-line: no-let +let logger: Context["log"] | undefined; +const contextTransport = new AzureContextTransport(() => logger, { + level: "debug" +}); +winston.add(contextTransport); + +const azureFunctionHandler = createAzureFunctionHandler(app); + +// Binds the express app to an Azure Function handler +function httpStart(context: Context): void { + logger = context.log; + setAppContext(app, context); + azureFunctionHandler(context); +} + +export default httpStart; diff --git a/jest.config.js b/jest.config.js index 91a2d2c0..8866385e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', -}; \ No newline at end of file + preset: "ts-jest", + testEnvironment: "node", + testPathIgnorePatterns: ["dist", "/node_modules"] +}; diff --git a/utils/arbitraries.ts b/utils/arbitraries.ts index eef88f84..581e516c 100644 --- a/utils/arbitraries.ts +++ b/utils/arbitraries.ts @@ -1,7 +1,13 @@ import * as assert from "assert"; - import * as fc from "fast-check"; - +import { some } from "fp-ts/lib/Option"; +import { BlockedInboxOrChannelEnum } from "io-functions-commons/dist/generated/definitions/BlockedInboxOrChannel"; +import { NewMessage } from "io-functions-commons/dist/generated/definitions/NewMessage"; +import { PreferredLanguageEnum } from "io-functions-commons/dist/generated/definitions/PreferredLanguage"; +import { NewMessageWithoutContent } from "io-functions-commons/dist/src/models/message"; +import { Profile } from "io-functions-commons/dist/src/models/profile"; +import { Service } from "io-functions-commons/dist/src/models/service"; +import { ClientIp } from "io-functions-commons/dist/src/utils/middlewares/client_ip_middleware"; import { NonNegativeNumber, WithinRangeInteger @@ -13,10 +19,6 @@ import { PatternString } from "italia-ts-commons/lib/strings"; -import { NewMessage } from "io-functions-commons/dist/generated/definitions/NewMessage"; -import { NewMessageWithoutContent } from "io-functions-commons/dist/src/models/message"; -import { Service } from "io-functions-commons/dist/src/models/service"; - // // custom fastcheck arbitraries // @@ -65,6 +67,8 @@ export const fiscalCodeArrayArb = fc.array(fiscalCodeArb); export const fiscalCodeSetArb = fiscalCodeArrayArb.map(_ => new Set(_)); +export const clientIpArb = fc.ipV4().map(_ => some(_) as ClientIp); + const messageContentSubject = fc.string(10, 120); const messageContentMarkdown = fc.string(80, 10000); @@ -206,3 +210,15 @@ export const versionedServiceArb = fc ...service, version: version as NonNegativeNumber })); + +export const profileArb = fc.tuple(fiscalCodeArb, fc.emailAddress()).map( + ([fiscalCode, email]) => + ({ + blockedInboxOrChannels: { + "01234567890": new Set([BlockedInboxOrChannelEnum.INBOX]) + }, + email: email as EmailString, + fiscalCode, + preferredLanguages: [PreferredLanguageEnum.en_GB] + } as Profile) +);