diff --git a/UploadOrganizationLogo/__tests__/handler.test.ts b/UploadOrganizationLogo/__tests__/handler.test.ts new file mode 100644 index 00000000..bfd8ea2a --- /dev/null +++ b/UploadOrganizationLogo/__tests__/handler.test.ts @@ -0,0 +1,155 @@ +/* tslint:disable:no-any */ +/* tslint:disable:no-duplicate-string */ +/* tslint:disable:no-big-function */ +/* tslint:disable: no-identical-functions */ + +import { + IAzureApiAuthorization, + UserGroup +} 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 { NonNegativeInteger } from "italia-ts-commons/lib/numbers"; +import { + EmailString, + NonEmptyString, + OrganizationFiscalCode +} from "italia-ts-commons/lib/strings"; + +import { toAuthorizedCIDRs } from "io-functions-commons/dist/src/models/service"; + +import { MaxAllowedPaymentAmount } from "io-functions-commons/dist/generated/definitions/MaxAllowedPaymentAmount"; + +import { left, right } from "fp-ts/lib/Either"; +import * as reporters from "italia-ts-commons/lib/reporters"; +import { Logo } from "../../generated/api-admin/Logo"; +import { UploadOrganizationLogoHandler } from "../handler"; + +const mockContext = { + // tslint:disable: no-console + log: { + error: console.error + } +} as any; + +afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); +}); + +const anOrganizationFiscalCode = "01234567890" as OrganizationFiscalCode; +const anEmail = "test@example.com" as EmailString; + +const aServiceId = "s123" as NonEmptyString; +const someSubscriptionKeys = { + primary_key: "primary_key", + secondary_key: "secondary_key" +}; + +const aService = { + authorizedCIDRs: toAuthorizedCIDRs([]), + authorizedRecipients: new Set([]), + departmentName: "IT" as NonEmptyString, + isVisible: true, + maxAllowedPaymentAmount: 0 as MaxAllowedPaymentAmount, + organizationFiscalCode: anOrganizationFiscalCode, + organizationName: "AgID" as NonEmptyString, + requireSecureChannels: false, + scope: "NATIONAL", + serviceId: aServiceId, + serviceName: "Test" as NonEmptyString, + version: 1 as NonNegativeInteger, + ...someSubscriptionKeys +}; + +const someUserAttributes: IAzureUserAttributes = { + email: anEmail, + kind: "IAzureUserAttributes", + service: aService +}; + +const aUserAuthenticationDeveloper: IAzureApiAuthorization = { + groups: new Set([UserGroup.ApiServiceRead, UserGroup.ApiServiceWrite]), + kind: "IAzureApiAuthorization", + subscriptionId: aServiceId, + userId: "u123" as NonEmptyString +}; + +const aLogoPayload: Logo = { + logo: "base64-logo-img" as NonEmptyString +}; + +describe("UploadOrganizationLogo", () => { + it("should respond with 202 if logo upload was successfull", async () => { + const apiClientMock = { + uploadOrganizationLogo: jest.fn(() => + Promise.resolve(right({ status: 201 })) + ) + }; + + const uploadOrganizationLogoHandler = UploadOrganizationLogoHandler( + apiClientMock as any + ); + const result = await uploadOrganizationLogoHandler( + mockContext, + aUserAuthenticationDeveloper, + undefined as any, // not used + someUserAttributes, + anOrganizationFiscalCode, + aLogoPayload + ); + + expect(result.kind).toBe("IResponseSuccessAccepted"); + if (result.kind === "IResponseSuccessAccepted") { + expect(result.detail).toBeUndefined(); + } + }); + + it("should respond with an internal error if upload service logo does not respond", async () => { + const apiClientMock = { + uploadOrganizationLogo: jest.fn(() => + Promise.reject(new Error("Timeout")) + ) + }; + + const uploadOrganizationLogoHandler = UploadOrganizationLogoHandler( + apiClientMock as any + ); + const result = await uploadOrganizationLogoHandler( + mockContext, + aUserAuthenticationDeveloper, + undefined as any, // not used + someUserAttributes, + anOrganizationFiscalCode, + aLogoPayload + ); + + expect(result.kind).toBe("IResponseErrorInternal"); + }); + + it("should respond with an internal error if uploadOrganizationLogo returns Errors", async () => { + const apiClientMock = { + uploadOrganizationLogo: jest.fn(() => + Promise.resolve(left({ err: "ValidationError" })) + ) + }; + + jest + .spyOn(reporters, "errorsToReadableMessages") + .mockImplementation(() => ["ValidationErrors"]); + + const uploadOrganizationLogoHandler = UploadOrganizationLogoHandler( + apiClientMock as any + ); + const result = await uploadOrganizationLogoHandler( + mockContext, + aUserAuthenticationDeveloper, + undefined as any, // not used + someUserAttributes, + anOrganizationFiscalCode, + aLogoPayload + ); + + expect(result.kind).toBe("IResponseErrorInternal"); + }); +}); diff --git a/UploadOrganizationLogo/function.json b/UploadOrganizationLogo/function.json new file mode 100644 index 00000000..fc238c11 --- /dev/null +++ b/UploadOrganizationLogo/function.json @@ -0,0 +1,20 @@ +{ + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "route": "v1/organizations/{organization_fiscal_code}/logo", + "methods": [ + "put" + ] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ], + "scriptFile": "../dist/UploadOrganizationLogo/index.js" +} \ No newline at end of file diff --git a/UploadOrganizationLogo/handler.ts b/UploadOrganizationLogo/handler.ts new file mode 100644 index 00000000..efed3dbd --- /dev/null +++ b/UploadOrganizationLogo/handler.ts @@ -0,0 +1,140 @@ +import * as express from "express"; + +import { + ClientIp, + ClientIpMiddleware +} from "io-functions-commons/dist/src/utils/middlewares/client_ip_middleware"; + +import { RequiredParamMiddleware } from "io-functions-commons/dist/src/utils/middlewares/required_param"; + +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 { + withRequestMiddlewares, + wrapRequestHandler +} from "io-functions-commons/dist/src/utils/request_middleware"; +import { + IResponseErrorForbiddenNotAuthorized, + IResponseErrorInternal, + IResponseErrorTooManyRequests, + IResponseSuccessAccepted, + ResponseSuccessAccepted +} from "italia-ts-commons/lib/responses"; +import { OrganizationFiscalCode } from "italia-ts-commons/lib/strings"; + +import { + checkSourceIpForHandler, + clientIPAndCidrTuple as ipTuple +} from "io-functions-commons/dist/src/utils/source_ip_check"; + +import { Context } from "@azure/functions"; +import { identity } from "fp-ts/lib/function"; +import { TaskEither } from "fp-ts/lib/TaskEither"; +import { ServiceModel } from "io-functions-commons/dist/src/models/service"; +import { ContextMiddleware } from "io-functions-commons/dist/src/utils/middlewares/context_middleware"; +import { RequiredBodyPayloadMiddleware } from "io-functions-commons/dist/src/utils/middlewares/required_body_payload"; +import { APIClient } from "../clients/admin"; +import { Logo } from "../generated/api-admin/Logo"; +import { withApiRequestWrapper } from "../utils/api"; +import { getLogger, ILogger } from "../utils/logging"; +import { + ErrorResponses, + IResponseErrorUnauthorized, + toDefaultResponseErrorInternal +} from "../utils/responses"; + +type ResponseTypes = + | IResponseSuccessAccepted + | IResponseErrorUnauthorized + | IResponseErrorForbiddenNotAuthorized + | IResponseErrorTooManyRequests + | IResponseErrorInternal; + +const logPrefix = "UploadOrganizationLogoHandler"; + +/** + * Type of a UploadOrganizationLogoHandler handler. + * + * UploadOrganizationLogo expects an organization fiscal code and a logo as input + * and returns informations about upload outcome + */ +type IUploadOrganizationLogoHandler = ( + context: Context, + auth: IAzureApiAuthorization, + clientIp: ClientIp, + attrs: IAzureUserAttributes, + organizationFiscalCode: OrganizationFiscalCode, + logoPayload: Logo +) => Promise; + +const uploadOrganizationLogoTask = ( + logger: ILogger, + apiClient: APIClient, + organizationFiscalCode: OrganizationFiscalCode, + logo: Logo +): TaskEither => + withApiRequestWrapper( + logger, + () => + apiClient.uploadOrganizationLogo({ + body: logo, + organization_fiscal_code: organizationFiscalCode + }), + 201 + ).map(_ => ResponseSuccessAccepted()); + +/** + * Handles requests for upload an organization logo. + */ +export function UploadOrganizationLogoHandler( + apiClient: APIClient +): IUploadOrganizationLogoHandler { + return (_, __, ___, ____, organizationFiscalCode, logoPayload) => { + return uploadOrganizationLogoTask( + getLogger(_, logPrefix, "UploadOrganizationLogo"), + apiClient, + organizationFiscalCode, + logoPayload + ) + .mapLeft(errs => + // Not found is never returned by uploadOrganizationLogo but, due to request wrapping return type, we have to wrap it + errs.kind !== "IResponseErrorNotFound" + ? errs + : toDefaultResponseErrorInternal(errs) + ) + .fold(identity, identity) + .run(); + }; +} + +/** + * Wraps a UploadOrganizationLogo handler inside an Express request handler. + */ +export function UploadOrganizationLogo( + serviceModel: ServiceModel, + client: APIClient +): express.RequestHandler { + const handler = UploadOrganizationLogoHandler(client); + const middlewaresWrap = withRequestMiddlewares( + ContextMiddleware(), + AzureApiAuthMiddleware(new Set([UserGroup.ApiServiceWrite])), + ClientIpMiddleware, + AzureUserAttributesMiddleware(serviceModel), + RequiredParamMiddleware("organization_fiscal_code", OrganizationFiscalCode), + RequiredBodyPayloadMiddleware(Logo) + ); + return wrapRequestHandler( + middlewaresWrap( + checkSourceIpForHandler(handler, (_, __, c, u, ___, ____) => + ipTuple(c, u) + ) + ) + ); +} diff --git a/UploadOrganizationLogo/index.ts b/UploadOrganizationLogo/index.ts new file mode 100644 index 00000000..6172d44c --- /dev/null +++ b/UploadOrganizationLogo/index.ts @@ -0,0 +1,38 @@ +import { Context } from "@azure/functions"; +import * as express from "express"; +import { cosmosdbInstance } from "../utils/cosmosdb"; + +import { + SERVICE_COLLECTION_NAME, + ServiceModel +} from "io-functions-commons/dist/src/models/service"; +import { secureExpressApp } from "io-functions-commons/dist/src/utils/express"; +import { setAppContext } from "io-functions-commons/dist/src/utils/middlewares/context_middleware"; + +import createAzureFunctionHandler from "io-functions-express/dist/src/createAzureFunctionsHandler"; + +import { apiClient } from "../clients/admin"; +import { UploadOrganizationLogo } from "./handler"; + +// Setup Express +const app = express(); +secureExpressApp(app); + +const serviceModel = new ServiceModel( + cosmosdbInstance.container(SERVICE_COLLECTION_NAME) +); + +app.put( + "/api/v1/organizations/:organization_fiscal_code/logo", + UploadOrganizationLogo(serviceModel, apiClient) +); + +const azureFunctionHandler = createAzureFunctionHandler(app); + +// Binds the express app to an Azure Function handler +function httpStart(context: Context): void { + setAppContext(app, context); + azureFunctionHandler(context); +} + +export default httpStart; diff --git a/openapi/index.yaml b/openapi/index.yaml index ab35e66f..11e615ab 100644 --- a/openapi/index.yaml +++ b/openapi/index.yaml @@ -541,6 +541,38 @@ paths: description: Cannot regenerate service key. schema: $ref: "#/definitions/ProblemJson" + "/organizations/{organization_fiscal_code}/logo": + parameters: + - $ref: "#/parameters/OrganizationFiscalCode" + put: + summary: Upload organization logo. + description: | + Upsert a logo for an Organization. + operationId: uploadOrganizationLogo + parameters: + - name: body + in: body + required: true + schema: + $ref: "#/definitions/Logo" + description: A base64 string representation of the organization logo PNG image. + responses: + "202": + description: Logo uploaded. + "400": + description: Invalid payload. + schema: + $ref: "#/definitions/ProblemJson" + "401": + description: Unauthorized. + "403": + description: Forbidden. + "429": + description: Too many requests. + "500": + description: The organization logo cannot be uploaded. + schema: + $ref: "#/definitions/ProblemJson" definitions: GetLimitedProfileByPOSTPayload: type: object @@ -684,6 +716,14 @@ parameters: description: A date in the format YYYY-MM-DD. pattern: "[0-9]{4}-[0-9]{2}-[0-9]{2}" x-example: "2019-09-15" + OrganizationFiscalCode: + name: organization_fiscal_code + in: path + type: string + required: true + description: Organization fiscal code. + format: OrganizationFiscalCode + x-import: italia-ts-commons/lib/strings consumes: - application/json produces: