diff --git a/.changeset/dirty-phones-train.md b/.changeset/dirty-phones-train.md new file mode 100644 index 000000000..0a1cf16d3 --- /dev/null +++ b/.changeset/dirty-phones-train.md @@ -0,0 +1,6 @@ +--- +"io-services-cms-webapp": minor +"@io-services-cms/models": patch +--- + +Add Upsert and Get Service activation API diff --git a/apps/io-services-cms-webapp/GetServiceActivation/function.json b/apps/io-services-cms-webapp/GetServiceActivation/function.json new file mode 100644 index 000000000..729b17013 --- /dev/null +++ b/apps/io-services-cms-webapp/GetServiceActivation/function.json @@ -0,0 +1,19 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "route": "activations", + "methods": ["post"] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ], + "scriptFile": "../dist/main.js", + "entryPoint": "httpEntryPoint" +} diff --git a/apps/io-services-cms-webapp/UpsertServiceActivation/function.json b/apps/io-services-cms-webapp/UpsertServiceActivation/function.json new file mode 100644 index 000000000..6631a053a --- /dev/null +++ b/apps/io-services-cms-webapp/UpsertServiceActivation/function.json @@ -0,0 +1,19 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "route": "activations", + "methods": ["put"] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ], + "scriptFile": "../dist/main.js", + "entryPoint": "httpEntryPoint" +} diff --git a/apps/io-services-cms-webapp/openapi.yaml b/apps/io-services-cms-webapp/openapi.yaml index 4ac165536..4b5285ae2 100644 --- a/apps/io-services-cms-webapp/openapi.yaml +++ b/apps/io-services-cms-webapp/openapi.yaml @@ -738,6 +738,93 @@ paths: description: Too many requests '500': description: Internal server error + /activations/: + post: + tags: + - activations + operationId: getServiceActivationByPOST + summary: Get a Service Activation for a User + description: Returns the current Activation for a couple Service/User + parameters: + - $ref: '#/components/parameters/UserGroups' + - $ref: '#/components/parameters/SubscriptionID' + - $ref: '#/components/parameters/UserEmail' + - $ref: '#/components/parameters/UserID' + requestBody: + description: A fiscal code payload + content: + application/json: + schema: + $ref: '#/components/schemas/FiscalCodePayload' + required: true + responses: + '200': + description: Service Activation fetched successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Activation' + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: No user activation found for the provided fiscal code + content: + application/json: + schema: + $ref: '#/components/schemas/ResponseError' + '429': + description: Too many requests + '500': + description: Internal server error retrieving the Activation + content: + application/json: + schema: + $ref: '#/components/schemas/ResponseError' + put: + tags: + - activations + operationId: upsertServiceActivation + summary: Upsert a Service Activation for a User + description: Create or update an Activation for a couple Service/User + parameters: + - $ref: '#/components/parameters/UserGroups' + - $ref: '#/components/parameters/SubscriptionID' + - $ref: '#/components/parameters/UserEmail' + - $ref: '#/components/parameters/UserID' + requestBody: + description: A special service's activation body payload + content: + application/json: + schema: + $ref: '#/components/schemas/ActivationPayload' + required: true + responses: + '200': + description: Service Activation created or updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Activation' + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: No user activation found for the provided fiscal code. + content: + application/json: + schema: + $ref: '#/components/schemas/ResponseError' + '429': + description: Too many requests + '500': + description: The activation cannot be created or updated + content: + application/json: + schema: + $ref: '#/components/schemas/ResponseError' components: parameters: UserEmail: @@ -858,3 +945,9 @@ components: $ref: 'https://raw.githubusercontent.com/pagopa/io-services-cms/master/packages/io-services-cms-models/openapi/services-cms-schemas.yaml#/ReviewRequest' Timestamp: $ref: 'https://raw.githubusercontent.com/pagopa/io-services-cms/master/packages/io-services-cms-models/openapi/commons-schemas.yaml#/Timestamp' + Activation: + $ref: '../../packages/io-services-cms-models/openapi/services-cms-schemas.yaml#/Activation' + ActivationPayload: + $ref: '../../packages/io-services-cms-models/openapi/services-cms-schemas.yaml#/ActivationPayload' + FiscalCodePayload: + $ref: '../../packages/io-services-cms-models/openapi/services-cms-schemas.yaml#/FiscalCodePayload' diff --git a/apps/io-services-cms-webapp/src/main.ts b/apps/io-services-cms-webapp/src/main.ts index 21f9443d5..18ef4e1c0 100644 --- a/apps/io-services-cms-webapp/src/main.ts +++ b/apps/io-services-cms-webapp/src/main.ts @@ -181,7 +181,9 @@ const legacyServicesContainer = cosmosdbClient const legacyServiceModel = new ServiceModel(legacyServicesContainer); -const blobService = createBlobService(config.ASSET_STORAGE_CONNECTIONSTRING); +const assetBlobService = createBlobService( + config.ASSET_STORAGE_CONNECTIONSTRING, +); // eventhub producer for ServicePublication const servicePublicationEventHubProducer = new EventHubProducerClient( @@ -221,9 +223,12 @@ const blobServiceClient = new BlobServiceClient( // entrypoint for all http functions export const httpEntryPoint = pipe( { + activationsContainerClient: blobServiceClient.getContainerClient( + config.ACTIVATIONS_CONTAINER_NAME, + ), apimService, + assetBlobService, basePath: BASE_PATH, - blobService, config, fsmLifecycleClientCreator, fsmPublicationClient, diff --git a/apps/io-services-cms-webapp/src/utils/__tests__/special-services.test.ts b/apps/io-services-cms-webapp/src/utils/__tests__/special-services.test.ts new file mode 100644 index 000000000..cceaceaca --- /dev/null +++ b/apps/io-services-cms-webapp/src/utils/__tests__/special-services.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import * as E from "fp-ts/lib/Either"; +import { ResponseErrorForbiddenNotAuthorized } from "@pagopa/ts-commons/lib/responses"; +import { authorizedForSpecialServicesTask } from "../special-services"; + +describe("authorizedForSpecialServicesTask", () => { + it("should return the category if it is SPECIAL_CATEGORY", async () => { + const result = await authorizedForSpecialServicesTask("SPECIAL")(); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toBe("SPECIAL"); + } + }); + + it("should return a forbidden error if the category is not SPECIAL_CATEGORY", async () => { + const result = await authorizedForSpecialServicesTask("STANDARD")(); + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toEqual(ResponseErrorForbiddenNotAuthorized); + } + }); + + it("should return a forbidden error if the category is undefined", async () => { + const result = await authorizedForSpecialServicesTask(undefined)(); + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toEqual(ResponseErrorForbiddenNotAuthorized); + } + }); +}); diff --git a/apps/io-services-cms-webapp/src/utils/applicationinsight.ts b/apps/io-services-cms-webapp/src/utils/applicationinsight.ts index fc1fadeae..95e0cf7e4 100644 --- a/apps/io-services-cms-webapp/src/utils/applicationinsight.ts +++ b/apps/io-services-cms-webapp/src/utils/applicationinsight.ts @@ -45,6 +45,7 @@ export enum EventNameEnum { CreateService = "create", DeleteService = "delete", EditService = "edit", + GetServiceActivation = "activation.get", GetServiceHistory = "history.get", GetServiceKeys = "keys.get", GetServiceLifecycle = "lifecycle.get", @@ -56,4 +57,5 @@ export enum EventNameEnum { RegenerateServiceKeys = "keys.regenerate", UnpublishService = "unpublish", UploadServiceLogo = "logo.put", + UpsertServiceActivation = "activation.upsert", } diff --git a/apps/io-services-cms-webapp/src/utils/special-services.ts b/apps/io-services-cms-webapp/src/utils/special-services.ts new file mode 100644 index 000000000..946ea5319 --- /dev/null +++ b/apps/io-services-cms-webapp/src/utils/special-services.ts @@ -0,0 +1,24 @@ +import { ServiceLifecycle } from "@io-services-cms/models"; +import { + IResponseErrorForbiddenNotAuthorized, + ResponseErrorForbiddenNotAuthorized, +} from "@pagopa/ts-commons/lib/responses"; +import * as O from "fp-ts/lib/Option"; +import * as TE from "fp-ts/lib/TaskEither"; +import { pipe } from "fp-ts/lib/function"; + +const SPECIAL_CATEGORY: ServiceLifecycle.definitions.Service["data"]["metadata"]["category"] = + "SPECIAL"; + +// Authorization check: only services of SPECIAL_CATEGORY can proceed +export const authorizedForSpecialServicesTask = ( + category: ServiceLifecycle.definitions.Service["data"]["metadata"]["category"], +): TE.TaskEither< + IResponseErrorForbiddenNotAuthorized, + ServiceLifecycle.definitions.Service["data"]["metadata"]["category"] +> => + pipe( + O.fromNullable(category), + O.filter((cat) => cat === SPECIAL_CATEGORY), + TE.fromOption(() => ResponseErrorForbiddenNotAuthorized), + ); diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/create-service.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/create-service.test.ts index f8f3fbd3c..f2a0882ac 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/create-service.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/create-service.test.ts @@ -27,6 +27,7 @@ const { itemToResponseResponseMock, logErrorResponseMock, getLoggerMock, + cosmosdbInstanceMock, } = vi.hoisted(() => { //const payloadToItemResponseMock = "payloadToItemResponse"; const payloadToItemResponseMock = { @@ -47,6 +48,9 @@ const { itemToResponseResponseMock, logErrorResponseMock, getLoggerMock: vi.fn(() => ({ logErrorResponse: logErrorResponseMock })), + cosmosdbInstanceMock: { + container: vi.fn(() => ({})), + }, }; }); @@ -63,6 +67,10 @@ vi.mock("../../../utils/logger", () => ({ getLogger: getLoggerMock, })); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: cosmosdbInstanceMock, +})); + const aNewService = { name: "a service", description: "a description", @@ -147,7 +155,6 @@ describe("createService", () => { fsmPublicationClient: vi.fn(), subscriptionCIDRsModel, telemetryClient: mockAppinsights, - blobService: vi.fn(), serviceTopicDao: vi.fn(), } as unknown as WebServerDependencies); diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/delete-service.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/delete-service.test.ts index 57a344e2d..d6c7a6541 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/delete-service.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/delete-service.test.ts @@ -23,6 +23,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { IConfig } from "../../../config"; import { WebServerDependencies, createWebServer } from "../../index"; +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + const serviceLifecycleStore = stores.createMemoryStore(); const fsmLifecycleClientCreator = ServiceLifecycle.getFsmClient( diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/edit-service.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/edit-service.test.ts index b793f4fb3..9f3d4bf77 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/edit-service.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/edit-service.test.ts @@ -56,6 +56,7 @@ const { getLoggerMock, fsmLifecycleClientMock, fsmLifecycleClientCreatorMock, + cosmosdbInstanceMock, } = vi.hoisted(() => { const payloadToItemResponseMock = "payloadToItemResponse"; const itemToResponseResponseMock = { value: "itemToResponseResponse" }; @@ -84,6 +85,9 @@ const { getLoggerMock: vi.fn(() => ({ logErrorResponse: logErrorResponseMock })), fsmLifecycleClientMock, fsmLifecycleClientCreatorMock: vi.fn(() => fsmLifecycleClientMock), + cosmosdbInstanceMock: { + container: vi.fn(() => ({})), + }, }; }); @@ -104,6 +108,10 @@ vi.mock("../../../utils/logger", () => ({ getLogger: getLoggerMock, })); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: cosmosdbInstanceMock, +})); + const aManageSubscriptionId = "MANAGE-123"; const anUserId = "123"; diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-activation.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-activation.test.ts new file mode 100644 index 000000000..5cf1c1dd9 --- /dev/null +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-activation.test.ts @@ -0,0 +1,257 @@ +import { ContainerClient, RestError } from "@azure/storage-blob"; +import { ServiceModel } from "@pagopa/io-functions-commons/dist/src/models/service"; +import { UserGroup } from "@pagopa/io-functions-commons/dist/src/utils/middlewares/azure_api_auth"; +import { setAppContext } from "@pagopa/io-functions-commons/dist/src/utils/middlewares/context_middleware"; +import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; +import * as O from "fp-ts/lib/Option"; +import * as TE from "fp-ts/lib/TaskEither"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { IConfig } from "../../../config"; +import { WebServerDependencies, createWebServer } from "../../index"; +import { Activations } from "@io-services-cms/models"; + +const { + getLoggerMock, + logErrorResponseMock, + authorizedForSpecialServicesTaskMock, + downloadToBufferMock, + getBlockBlobClientMock, + serviceModelMock, + findLastVersionByModelIdMock, + aMockService, +} = vi.hoisted(() => { + const logErrorResponseMock = vi.fn((err) => err); + const getLoggerMock = vi.fn(() => ({ + logErrorResponse: logErrorResponseMock, + })); + + const authorizedForSpecialServicesTaskMock = vi.fn(() => TE.right(void 0)); + + const downloadToBufferMock = vi.fn(); + const getBlockBlobClientMock = vi.fn(() => ({ + downloadToBuffer: downloadToBufferMock, + })); + + const aMockService = { + id: "key-123" as NonEmptyString, + serviceId: "id-123" as NonEmptyString, + serviceName: "Test Service" as NonEmptyString, + organizationName: "Test Org" as NonEmptyString, + organizationFiscalCode: "99999999999" as NonEmptyString, + authorizedCIDRs: [], + authorizedRecipients: [], + maxAllowedPaymentAmount: 1000, + metadata: { + scope: "LOCAL" as NonEmptyString, + category: "STANDARD" as NonEmptyString, + }, + status: { value: "approved" }, + version: 1, + }; + + const findLastVersionByModelIdMock = vi.fn(() => + TE.right(O.some(aMockService)), + ); + const serviceModelMock = vi.fn(() => ({ + findLastVersionByModelId: findLastVersionByModelIdMock, + })); + + return { + getLoggerMock, + logErrorResponseMock, + authorizedForSpecialServicesTaskMock, + downloadToBufferMock, + getBlockBlobClientMock, + serviceModelMock, + findLastVersionByModelIdMock, + aMockService, + }; +}); + +const activationsContainerClientMock = { + getBlockBlobClient: getBlockBlobClientMock, +} as unknown as ContainerClient; + +vi.mock("../../../utils/logger", () => ({ + getLogger: getLoggerMock, +})); + +vi.mock("../../../utils/special-services", () => ({ + authorizedForSpecialServicesTask: authorizedForSpecialServicesTaskMock, +})); + +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + +const aFiscalCode = "RSSMRC80A01L219Q" as NonEmptyString; +const aServiceId = "id-123" as NonEmptyString; +const anOcpApimSubscriptionKey = "key-123" as NonEmptyString; +const anUserId = "uid-123"; + +const aMockActivation: Activations.Activation = { + fiscalCode: aFiscalCode, + serviceId: aServiceId, + status: "ACTIVE", + modifiedAt: new Date(), +} as unknown as Activations.Activation; + +vi.mock("@pagopa/io-functions-commons/dist/src/models/service", () => ({ + SERVICE_COLLECTION_NAME: "services", + ServiceModel: serviceModelMock, +})); + +const fsmLifecycleClientMock = { create: vi.fn(() => TE.right(aMockService)) }; +const fsmLifecycleClientCreatorMock = vi.fn(() => fsmLifecycleClientMock); + +const mockAppinsights = { + trackEvent: vi.fn(), + trackError: vi.fn(), +} as any; + +const mockContext = { + executionContext: { + functionName: "upsertServiceActivation", + }, +} as any; +const mockConfig = {} as IConfig; + +describe("getServiceActivation", () => { + const app = createWebServer({ + basePath: "api", + config: mockConfig, + serviceModel: serviceModelMock, + telemetryClient: mockAppinsights, + activationsContainerClient: activationsContainerClientMock, + fsmLifecycleClientCreator: fsmLifecycleClientCreatorMock, + } as unknown as WebServerDependencies); + + setAppContext(app, mockContext); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a 200 OK with the activation details if found", async () => { + // given + downloadToBufferMock.mockResolvedValueOnce( + Buffer.from(JSON.stringify(aMockActivation)), + ); + + // when + const response = await request(app) + .post("/api/activations") + .send({ fiscal_code: aFiscalCode }) + .set("x-user-groups", UserGroup.ApiServiceWrite) + .set("x-subscription-id", anOcpApimSubscriptionKey) + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(200); + expect(response.body.fiscal_code).toBe(aMockActivation.fiscalCode); + expect(response.body.service_id).toBe(aMockActivation.serviceId); + expect(response.body.status).toBe("ACTIVE"); + expect(getBlockBlobClientMock).toHaveBeenCalledWith( + `${aFiscalCode}/${aServiceId}.json`, + ); + expect(mockAppinsights.trackEvent).toHaveBeenCalledOnce(); + }); + + it("should return a 404 Not Found if the activation blob does not exist", async () => { + // given + const restError = new RestError("Blob not found", { statusCode: 404 }); + downloadToBufferMock.mockRejectedValueOnce(restError); + + // when + const response = await request(app) + .post("/api/activations") + .send({ fiscal_code: aFiscalCode }) + .set("x-user-groups", UserGroup.ApiServiceWrite) + .set("x-subscription-id", anOcpApimSubscriptionKey) + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(404); + expect(response.body.detail).toBe("Activation not found for the user"); + expect(logErrorResponseMock).toHaveBeenCalledOnce(); + }); + + it("should return a 500 Internal Server Error for other storage errors", async () => { + // given + const genericError = new Error("Something went wrong"); + downloadToBufferMock.mockRejectedValueOnce(genericError); + + // when + const response = await request(app) + .post("/api/activations") + .send({ fiscal_code: aFiscalCode }) + .set("x-user-groups", UserGroup.ApiServiceWrite) + .set("x-subscription-id", anOcpApimSubscriptionKey) + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(500); + expect(response.body.detail).toContain("An unexpected error occurred"); + expect(logErrorResponseMock).toHaveBeenCalledOnce(); + }); + + it("should return a 400 Bad Request if the payload is invalid", async () => { + // when + const response = await request(app) + .post("/api/activations") + .send({ not_fiscal_code: "123" }) + .set("x-user-groups", UserGroup.ApiServiceWrite) + .set("x-subscription-id", anOcpApimSubscriptionKey) + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(400); + expect(downloadToBufferMock).not.toHaveBeenCalled(); + }); + + it("should return a 403 Forbidden if the user group is not allowed", async () => { + // when + const response = await request(app) + .post("/api/activations") + .send({ fiscal_code: aFiscalCode }) + .set("x-user-groups", "SomeOtherGroup") + .set("x-subscription-id", anOcpApimSubscriptionKey) + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(403); + expect(downloadToBufferMock).not.toHaveBeenCalled(); + }); + + it("should return a 403 if the service associated with the subscription key is not found", async () => { + // given + findLastVersionByModelIdMock.mockImplementationOnce(() => TE.right(O.none)); + + // when + const response = await request(app) + .post("/api/activations") + .send({ fiscal_code: aFiscalCode }) + .set("x-user-groups", UserGroup.ApiServiceWrite) + .set("x-subscription-id", "non-existent-sub-key") + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(403); + expect(downloadToBufferMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-history.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-history.test.ts index ffb4e7c81..e29425433 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-history.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-history.test.ts @@ -34,6 +34,12 @@ vi.mock("../../../utils/service-topic-dao", () => ({ getDao: getServiceTopicDao, })); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + const aManageSubscriptionId = "MANAGE-123"; const anUserId = "123"; const ownerId = `/an/owner/${anUserId}`; diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-keys.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-keys.test.ts index dcd6612fe..f275ad850 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-keys.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-keys.test.ts @@ -23,6 +23,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { IConfig } from "../../../config"; import { WebServerDependencies, createWebServer } from "../../index"; +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + const apiGetNotFoundServiceKeysFullPath = "/api/services/aNotFoundServiceId/keys"; const apiGetDeletedServiceKeysFullPath = "/api/services/aDeletedServiceId/keys"; diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-lifecycle.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-lifecycle.test.ts index c628e53e5..d7049d711 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-lifecycle.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-lifecycle.test.ts @@ -38,6 +38,12 @@ vi.mock("../../../utils/service-topic-dao", () => ({ getDao: getServiceTopicDao, })); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + const serviceLifecycleStore = stores.createMemoryStore(); const fsmLifecycleClientCreator = ServiceLifecycle.getFsmClient( diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-publication.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-publication.test.ts index be214b151..e09fc4d25 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-publication.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-publication.test.ts @@ -50,6 +50,12 @@ vi.mock("../../../utils/service-topic-dao", () => ({ getDao: getServiceTopicDao, })); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + const serviceLifecycleStore = stores.createMemoryStore(); const fsmLifecycleClientCreator = ServiceLifecycle.getFsmClient( diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-topics.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-topics.test.ts index e12d1878c..feb29c49a 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-topics.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-service-topics.test.ts @@ -45,6 +45,12 @@ vi.mock("../../../utils/service-topic-dao", () => ({ getDao: getServiceTopicDao, })); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + const serviceLifecycleStore = stores.createMemoryStore(); const fsmLifecycleClientCreator = ServiceLifecycle.getFsmClient( diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-services.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-services.test.ts index f9c1baf48..dd45545af 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-services.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/get-services.test.ts @@ -19,6 +19,12 @@ import { describe, expect, it, vi } from "vitest"; import { IConfig } from "../../../config"; import { WebServerDependencies, createWebServer } from "../../index"; +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + const aName1 = "a-name-1"; const aName2 = "a-name-2"; const aName3 = "a-name-3"; diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/patch-service.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/patch-service.test.ts index 1d2e3a109..6a3798d6a 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/patch-service.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/patch-service.test.ts @@ -64,6 +64,12 @@ vi.mock("../../../utils/logger", () => ({ getLogger: getLoggerMock, })); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + beforeEach(() => { vi.restoreAllMocks(); }); diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/publish-service.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/publish-service.test.ts index aa58ceeb4..8c36b9d9b 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/publish-service.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/publish-service.test.ts @@ -140,6 +140,12 @@ vi.mock("../../../utils/check-service", () => ({ checkService: vi.fn(() => checkServiceMock), })); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + afterEach(() => { vi.restoreAllMocks(); }); diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/regenerate-service-keys.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/regenerate-service-keys.test.ts index 6f264b068..ebac23a54 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/regenerate-service-keys.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/regenerate-service-keys.test.ts @@ -94,6 +94,7 @@ const { mapApimRestErrorWrapperMock, logErrorResponseMock, getLoggerMock, + cosmosdbInstanceMock, } = vi.hoisted(() => { const checkServiceMock = vi.fn(() => TE.right(undefined)); const mapApimRestErrorMock = vi.fn(); @@ -108,6 +109,9 @@ const { mapApimRestErrorWrapperMock: vi.fn(() => mapApimRestErrorMock), logErrorResponseMock, getLoggerMock: vi.fn(() => ({ logErrorResponse: logErrorResponseMock })), + cosmosdbInstanceMock: { + container: vi.fn(() => ({})), + }, }; }); @@ -130,6 +134,10 @@ vi.mock("@io-services-cms/external-clients", async (importOriginal) => { }; }); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: cosmosdbInstanceMock, +})); + beforeEach(() => { vi.restoreAllMocks(); }); diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/review-service.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/review-service.test.ts index 83f98b8e7..2cafa784c 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/review-service.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/review-service.test.ts @@ -25,6 +25,12 @@ import { IConfig } from "../../../config"; import { ReviewRequest } from "../../../generated/api/ReviewRequest"; import { WebServerDependencies, createWebServer } from "../../index"; +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + const serviceLifecycleStore = stores.createMemoryStore(); const fsmLifecycleClientCreator = ServiceLifecycle.getFsmClient( diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/unpublish-service.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/unpublish-service.test.ts index e28b45a4a..bb4223e32 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/unpublish-service.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/unpublish-service.test.ts @@ -141,6 +141,12 @@ vi.mock("../../../utils/check-service", () => ({ checkService: vi.fn(() => checkServiceMock), })); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + afterEach(() => { vi.restoreAllMocks(); }); diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/upload-service-logo.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/upload-service-logo.test.ts index ef483d6bf..2d96f4451 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/upload-service-logo.test.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/upload-service-logo.test.ts @@ -122,6 +122,12 @@ vi.mock("../../../utils/check-service", () => ({ checkService: vi.fn(() => checkServiceMock), })); +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + afterEach(() => { vi.restoreAllMocks(); }); @@ -135,7 +141,7 @@ describe("uploadServiceLogo", () => { fsmPublicationClient, subscriptionCIDRsModel, telemetryClient: mockAppinsights, - blobService: mockBlobService, + assetBlobService: mockBlobService, serviceTopicDao: mockServiceTopicDao, } as unknown as WebServerDependencies); diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/upsert-service-activation.test.ts b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/upsert-service-activation.test.ts new file mode 100644 index 000000000..5f3110ea1 --- /dev/null +++ b/apps/io-services-cms-webapp/src/webservice/controllers/__tests__/upsert-service-activation.test.ts @@ -0,0 +1,234 @@ +import { ContainerClient } from "@azure/storage-blob"; +import { ServiceModel } from "@pagopa/io-functions-commons/dist/src/models/service"; +import { UserGroup } from "@pagopa/io-functions-commons/dist/src/utils/middlewares/azure_api_auth"; +import { setAppContext } from "@pagopa/io-functions-commons/dist/src/utils/middlewares/context_middleware"; +import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; +import * as O from "fp-ts/lib/Option"; +import * as TE from "fp-ts/lib/TaskEither"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { IConfig } from "../../../config"; +import { WebServerDependencies, createWebServer } from "../../index"; +import { ActivationPayload } from "../../../generated/api/ActivationPayload"; +import { Service } from "@io-services-cms/models/service-lifecycle/definitions"; + +const { + getLoggerMock, + logErrorResponseMock, + authorizedForSpecialServicesTaskMock, + uploadDataMock, + getBlockBlobClientMock, + serviceModelMock, + findLastVersionByModelIdMock, +} = vi.hoisted(() => { + const logErrorResponseMock = vi.fn((err) => err); + const getLoggerMock = vi.fn(() => ({ + logErrorResponse: logErrorResponseMock, + functionName: "upsertServiceActivation", + })); + + const authorizedForSpecialServicesTaskMock = vi.fn(() => TE.right(void 0)); + + const uploadDataMock = vi.fn(); + const getBlockBlobClientMock = vi.fn(() => ({ + uploadData: uploadDataMock, + })); + + const findLastVersionByModelIdMock = vi.fn(); + const serviceModelMock = vi.fn(() => ({ + findLastVersionByModelId: findLastVersionByModelIdMock, + })); + + return { + getLoggerMock, + logErrorResponseMock, + authorizedForSpecialServicesTaskMock, + uploadDataMock, + getBlockBlobClientMock, + serviceModelMock, + findLastVersionByModelIdMock, + }; +}); + +const aFiscalCode = "RSSMRC80A01L219Q" as NonEmptyString; +const aServiceId = "sid-123" as NonEmptyString; +const anOcpApimSubscriptionKey = "sub-key-123" as NonEmptyString; +const anUserId = "uid-123"; +const aMockService = { + id: anOcpApimSubscriptionKey, + serviceId: aServiceId, + serviceName: "Test Service" as NonEmptyString, + organizationName: "Test Org" as NonEmptyString, + organizationFiscalCode: "99999999999" as NonEmptyString, + authorizedCIDRs: new Set(), + authorizedRecipients: [], + maxAllowedPaymentAmount: 1000, + metadata: { + scope: "LOCAL" as Service["data"]["metadata"]["scope"], + category: "SPECIAL" as Service["data"]["metadata"]["category"], + }, + status: { value: "approved" }, + version: 1, +}; + +findLastVersionByModelIdMock.mockImplementation(() => + TE.right(O.some(aMockService)), +); + +const activationsContainerClientMock = { + getBlockBlobClient: getBlockBlobClientMock, +} as unknown as ContainerClient; + +vi.mock("../../../utils/logger", () => ({ + getLogger: getLoggerMock, +})); + +vi.mock("../../../utils/special-services", () => ({ + authorizedForSpecialServicesTask: authorizedForSpecialServicesTaskMock, +})); + +vi.mock("../../../utils/cosmos-legacy", () => ({ + cosmosdbInstance: { + container: vi.fn(() => ({})), + }, +})); + +const fsmLifecycleClientMock = { create: vi.fn(() => TE.right(aMockService)) }; +const fsmLifecycleClientCreatorMock = vi.fn(() => fsmLifecycleClientMock); + +const anActivationPayload: ActivationPayload = { + fiscal_code: aFiscalCode, + status: "ACTIVE", +} as unknown as ActivationPayload; + +vi.mock("@pagopa/io-functions-commons/dist/src/models/service", () => ({ + SERVICE_COLLECTION_NAME: "services", + ServiceModel: serviceModelMock, +})); + +const mockAppinsights = { + trackEvent: vi.fn(), + trackError: vi.fn(), +} as any; + +const mockContext = { + executionContext: { + functionName: "upsertServiceActivation", + }, +} as any; +const mockConfig = {} as IConfig; + +describe("upsertServiceActivation", () => { + const app = createWebServer({ + basePath: "api", + config: mockConfig, + telemetryClient: mockAppinsights, + activationsContainerClient: activationsContainerClientMock, + fsmLifecycleClientCreator: fsmLifecycleClientCreatorMock, + } as unknown as WebServerDependencies); + + setAppContext(app, mockContext); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a 200 OK with the created activation details on success", async () => { + // given + uploadDataMock.mockResolvedValueOnce({ _response: { status: 200 } }); + + // when + const response = await request(app) + .put("/api/activations") + .send(anActivationPayload) + .set("x-user-groups", UserGroup.ApiServiceWrite) + .set("x-subscription-id", anOcpApimSubscriptionKey) + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(200); + expect(response.body.fiscal_code).toBe(anActivationPayload.fiscal_code); + expect(response.body.service_id).toBe(aServiceId); + expect(response.body.status).toBe(anActivationPayload.status); + expect(response.body.modified_at).toEqual(expect.any(String)); + expect(getBlockBlobClientMock).toHaveBeenCalledWith( + `${aFiscalCode}/${aServiceId}.json`, + ); + expect(uploadDataMock).toHaveBeenCalledOnce(); + expect(mockAppinsights.trackEvent).toHaveBeenCalledOnce(); + }); + + it("should return a 500 Internal Server Error if blob upload fails", async () => { + // given + const errorMessage = "Blob storage is down"; + uploadDataMock.mockRejectedValueOnce(new Error(errorMessage)); + + // when + const response = await request(app) + .put("/api/activations") + .send(anActivationPayload) + .set("x-user-groups", UserGroup.ApiServiceWrite) + .set("x-subscription-id", anOcpApimSubscriptionKey) + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(500); + expect(response.body.detail).toContain(errorMessage); + expect(logErrorResponseMock).toHaveBeenCalledOnce(); + }); + + it("should return a 400 Bad Request if the payload is missing required fields", async () => { + // when + const response = await request(app) + .put("/api/activations") + .send({ fiscal_code: aFiscalCode }) + .set("x-user-groups", UserGroup.ApiServiceWrite) + .set("x-subscription-id", anOcpApimSubscriptionKey) + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(400); + expect(uploadDataMock).not.toHaveBeenCalled(); + }); + + it("should return a 403 Forbidden if the user group is not authorized", async () => { + // when + const response = await request(app) + .put("/api/activations") + .send(anActivationPayload) + .set("x-user-groups", "SomeOtherGroup") + .set("x-subscription-id", anOcpApimSubscriptionKey) + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(403); + expect(uploadDataMock).not.toHaveBeenCalled(); + }); + + it("should return a 403 if the service associated with the subscription key is not found", async () => { + // given + findLastVersionByModelIdMock.mockImplementationOnce(() => TE.right(O.none)); + + // when + const response = await request(app) + .put("/api/activations") + .send(anActivationPayload) + .set("x-user-groups", UserGroup.ApiServiceWrite) + .set("x-subscription-id", anOcpApimSubscriptionKey) + .set("x-user-id", anUserId) + .set("x-client-ip", "127.0.0.1") + .set("x-user-email", "test@test.com"); + + // then + expect(response.statusCode).toBe(403); + expect(uploadDataMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/get-service-activation.ts b/apps/io-services-cms-webapp/src/webservice/controllers/get-service-activation.ts new file mode 100644 index 000000000..9196174e6 --- /dev/null +++ b/apps/io-services-cms-webapp/src/webservice/controllers/get-service-activation.ts @@ -0,0 +1,150 @@ +import { Context } from "@azure/functions"; +import { ContainerClient, RestError } from "@azure/storage-blob"; +import { Activations } from "@io-services-cms/models"; +import { ServiceModel } from "@pagopa/io-functions-commons/dist/src/models/service"; +import { + AzureApiAuthMiddleware, + IAzureApiAuthorization, + UserGroup, +} from "@pagopa/io-functions-commons/dist/src/utils/middlewares/azure_api_auth"; +import { + AzureUserAttributesMiddleware, + IAzureUserAttributes, +} from "@pagopa/io-functions-commons/dist/src/utils/middlewares/azure_user_attributes"; +import { + ClientIp, + ClientIpMiddleware, +} from "@pagopa/io-functions-commons/dist/src/utils/middlewares/client_ip_middleware"; +import { ContextMiddleware } from "@pagopa/io-functions-commons/dist/src/utils/middlewares/context_middleware"; +import { RequiredBodyPayloadMiddleware } from "@pagopa/io-functions-commons/dist/src/utils/middlewares/required_body_payload"; +import { + checkSourceIpForHandler, + clientIPAndCidrTuple as ipTuple, +} from "@pagopa/io-functions-commons/dist/src/utils/source_ip_check"; +import { + withRequestMiddlewares, + wrapRequestHandler, +} from "@pagopa/ts-commons/lib/request_middleware"; +import { + IResponseSuccessJson, + ResponseErrorInternal, + ResponseErrorNotFound, + ResponseSuccessJson, +} from "@pagopa/ts-commons/lib/responses"; +import * as TE from "fp-ts/lib/TaskEither"; +import { pipe } from "fp-ts/lib/function"; + +import { Activation, StatusEnum } from "../../generated/api/Activation"; +import { FiscalCodePayload } from "../../generated/api/FiscalCodePayload"; +import { + EventNameEnum, + TelemetryClient, + trackEventOnResponseOK, +} from "../../utils/applicationinsight"; +import { ErrorResponseTypes, getLogger } from "../../utils/logger"; +import { authorizedForSpecialServicesTask } from "../../utils/special-services"; + +type HandlerResponseTypes = + | ErrorResponseTypes + | IResponseSuccessJson; + +type IGetServiceActivationHandler = ( + context: Context, + auth: IAzureApiAuthorization, + clientIp: ClientIp, + attrs: IAzureUserAttributes, + payload: FiscalCodePayload, +) => Promise; + +interface Dependencies { + activationsContainerClient: ContainerClient; + telemetryClient: TelemetryClient; +} + +export const makeGetServiceActivationsHandler = + ({ + activationsContainerClient, + telemetryClient, + }: Dependencies): IGetServiceActivationHandler => + (context, _auth, __, userAttributes, { fiscal_code }) => { + const logPrefix = `${context.executionContext.functionName}|SERVICE_ID=${userAttributes.service.serviceId}`; + + const logger = getLogger(context, logPrefix); + const { serviceId, serviceMetadata, serviceName } = userAttributes.service; + return pipe( + authorizedForSpecialServicesTask(serviceMetadata?.category), + TE.chainW(() => + pipe( + TE.tryCatch( + () => + activationsContainerClient + .getBlockBlobClient(`${fiscal_code}/${serviceId}.json`) + .downloadToBuffer(), + (e: unknown) => { + if (e instanceof RestError && e.statusCode === 404) { + return ResponseErrorNotFound( + "Not Found", + "Activation not found for the user", + ); + } + + return ResponseErrorInternal( + `An unexpected error occurred: ${e instanceof Error ? e.message : "Unknown error"}`, + ); + }, + ), + TE.map( + (buffer) => JSON.parse(buffer.toString()) as Activations.Activation, + ), + TE.map((activation) => + pipe(activation, (activation) => ({ + fiscal_code: activation.fiscalCode, + modified_at: String(activation.modifiedAt), + service_id: activation.serviceId, + status: StatusEnum[activation.status as keyof typeof StatusEnum], + })), + ), + TE.map((result) => ResponseSuccessJson(result)), + TE.map( + trackEventOnResponseOK( + telemetryClient, + EventNameEnum.GetServiceActivation, + { + serviceId, + serviceName, + }, + ), + ), + TE.mapLeft((err) => + logger.logErrorResponse(err, { + fiscalCode: fiscal_code, + serviceId: userAttributes.service.serviceId, + }), + ), + ), + ), + TE.toUnion, + )(); + }; + +export const applyRequestMiddelwares = + (serviceModel: ServiceModel) => (handler: IGetServiceActivationHandler) => { + const middlewaresWrap = withRequestMiddlewares( + // extract the Azure functions context + ContextMiddleware(), + // only allow requests by users belonging to certain groups + AzureApiAuthMiddleware(new Set([UserGroup.ApiServiceWrite])), + // extract the client IP from the request + ClientIpMiddleware, + // check use-key + AzureUserAttributesMiddleware(serviceModel), + // validate the reuqest body to be in the expected shape + RequiredBodyPayloadMiddleware(FiscalCodePayload), + ); + return wrapRequestHandler( + middlewaresWrap( + // eslint-disable-next-line max-params + checkSourceIpForHandler(handler, (_, __, c, u, ___) => ipTuple(c, u)), + ), + ); + }; diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/upload-service-logo.ts b/apps/io-services-cms-webapp/src/webservice/controllers/upload-service-logo.ts index da1d18e1d..f3bb98118 100644 --- a/apps/io-services-cms-webapp/src/webservice/controllers/upload-service-logo.ts +++ b/apps/io-services-cms-webapp/src/webservice/controllers/upload-service-logo.ts @@ -68,7 +68,7 @@ interface Dependencies { // An instance of APIM Client apimService: ApimUtils.ApimService; // Client to Azure Blob Storage - blobService: BlobService; + assetBlobService: BlobService; fsmLifecycleClientCreator: ServiceLifecycle.FsmClientCreator; telemetryClient: TelemetryClient; } @@ -131,7 +131,7 @@ const uploadImage = export const makeUploadServiceLogoHandler = ({ apimService, - blobService, + assetBlobService, fsmLifecycleClientCreator, telemetryClient, }: Dependencies): IUploadServiceLogoHandler => @@ -153,7 +153,7 @@ export const makeUploadServiceLogoHandler = validateImage, TE.fromEither, TE.chainW(() => - uploadImage(blobService)(lowerCaseServiceId, bufferImage), + uploadImage(assetBlobService)(lowerCaseServiceId, bufferImage), ), ), ), diff --git a/apps/io-services-cms-webapp/src/webservice/controllers/upsert-service-activation.ts b/apps/io-services-cms-webapp/src/webservice/controllers/upsert-service-activation.ts new file mode 100644 index 000000000..bae19fd83 --- /dev/null +++ b/apps/io-services-cms-webapp/src/webservice/controllers/upsert-service-activation.ts @@ -0,0 +1,150 @@ +import { Context } from "@azure/functions"; +import { ContainerClient } from "@azure/storage-blob"; +import { Activations } from "@io-services-cms/models"; +import { ServiceModel } from "@pagopa/io-functions-commons/dist/src/models/service"; +import { + AzureApiAuthMiddleware, + IAzureApiAuthorization, + UserGroup, +} from "@pagopa/io-functions-commons/dist/src/utils/middlewares/azure_api_auth"; +import { + AzureUserAttributesMiddleware, + IAzureUserAttributes, +} from "@pagopa/io-functions-commons/dist/src/utils/middlewares/azure_user_attributes"; +import { + ClientIp, + ClientIpMiddleware, +} from "@pagopa/io-functions-commons/dist/src/utils/middlewares/client_ip_middleware"; +import { ContextMiddleware } from "@pagopa/io-functions-commons/dist/src/utils/middlewares/context_middleware"; +import { RequiredBodyPayloadMiddleware } from "@pagopa/io-functions-commons/dist/src/utils/middlewares/required_body_payload"; +import { + checkSourceIpForHandler, + clientIPAndCidrTuple as ipTuple, +} from "@pagopa/io-functions-commons/dist/src/utils/source_ip_check"; +import { + withRequestMiddlewares, + wrapRequestHandler, +} from "@pagopa/ts-commons/lib/request_middleware"; +import { + IResponseSuccessJson, + ResponseErrorInternal, + ResponseSuccessJson, +} from "@pagopa/ts-commons/lib/responses"; +import * as TE from "fp-ts/lib/TaskEither"; +import { pipe } from "fp-ts/lib/function"; + +// Assuming these are defined in your generated API types +import { Activation, StatusEnum } from "../../generated/api/Activation"; +import { ActivationPayload } from "../../generated/api/ActivationPayload"; +import { + EventNameEnum, + TelemetryClient, + trackEventOnResponseOK, +} from "../../utils/applicationinsight"; +import { ErrorResponseTypes, getLogger } from "../../utils/logger"; +import { authorizedForSpecialServicesTask } from "../../utils/special-services"; + +// Handler Response and Request Types +type HandlerResponseTypes = + | ErrorResponseTypes + | IResponseSuccessJson; + +type IUpsertServiceActivationHandler = ( + context: Context, + auth: IAzureApiAuthorization, + clientIp: ClientIp, + attrs: IAzureUserAttributes, + payload: ActivationPayload, +) => Promise; + +// Dependencies required by the handler +interface Dependencies { + activationsContainerClient: ContainerClient; + telemetryClient: TelemetryClient; +} + +export const makeUpsertServiceActivationHandler = + ({ + activationsContainerClient, + telemetryClient, + }: Dependencies): IUpsertServiceActivationHandler => + (context, _auth, __, userAttributes, { fiscal_code, status }) => { + const logPrefix = `${context.executionContext.functionName}|SERVICE_ID=${userAttributes.service.serviceId}`; + const logger = getLogger(context, logPrefix); + const { serviceId, serviceMetadata, serviceName } = userAttributes.service; + + // Create the internal activation model to be saved to blob storage + const activationToSave: Activations.Activation = { + fiscalCode: fiscal_code, + modifiedAt: new Date().getTime(), // Use current timestamp + serviceId, + status, + }; + + return pipe( + authorizedForSpecialServicesTask(serviceMetadata?.category), + TE.chainW(() => + pipe( + TE.tryCatch( + () => + activationsContainerClient + .getBlockBlobClient(`${fiscal_code}/${serviceId}.json`) + .uploadData(Buffer.from(JSON.stringify(activationToSave))), + (err: unknown) => + ResponseErrorInternal( + `Failed to upsert activation: ${err instanceof Error ? err.message : "Unknown error"}`, + ), + ), + // Map the model to the public API model + TE.map(() => ({ + fiscal_code: activationToSave.fiscalCode, + modified_at: String(activationToSave.modifiedAt), + service_id: activationToSave.serviceId, + status: + StatusEnum[activationToSave.status as keyof typeof StatusEnum], + })), + TE.map(ResponseSuccessJson), + TE.map( + trackEventOnResponseOK( + telemetryClient, + EventNameEnum.UpsertServiceActivation, + { + activationStatus: activationToSave.status, + serviceId, + serviceName, + }, + ), + ), + TE.mapLeft((err) => + logger.logErrorResponse(err, { + fiscalCode: fiscal_code, + serviceId, + }), + ), + ), + ), + TE.toUnion, + )(); + }; + +export const applyRequestMiddelwares = + (serviceModel: ServiceModel) => + (handler: IUpsertServiceActivationHandler) => { + const middlewaresWrap = withRequestMiddlewares( + // Extract Azure functions context + ContextMiddleware(), + // Ensure the user belongs to the 'ApiServiceWrite' group + AzureApiAuthMiddleware(new Set([UserGroup.ApiServiceWrite])), + // Extract the client IP + ClientIpMiddleware, + // Retrieve user attributes and service details from the subscription + AzureUserAttributesMiddleware(serviceModel), + // Validate the request body against the ActivationPayload schema + RequiredBodyPayloadMiddleware(ActivationPayload), + ); + return wrapRequestHandler( + middlewaresWrap( + checkSourceIpForHandler(handler, (_, __, c, u, ___) => ipTuple(c, u)), + ), + ); + }; diff --git a/apps/io-services-cms-webapp/src/webservice/index.ts b/apps/io-services-cms-webapp/src/webservice/index.ts index 8377d47aa..98a69ed02 100644 --- a/apps/io-services-cms-webapp/src/webservice/index.ts +++ b/apps/io-services-cms-webapp/src/webservice/index.ts @@ -1,9 +1,14 @@ +import { ContainerClient } from "@azure/storage-blob"; import { ApimUtils } from "@io-services-cms/external-clients"; import { ServiceHistory, ServiceLifecycle, ServicePublication, } from "@io-services-cms/models"; +import { + SERVICE_COLLECTION_NAME, + ServiceModel, +} from "@pagopa/io-functions-commons/dist/src/models/service"; import { SubscriptionCIDRsModel } from "@pagopa/io-functions-commons/dist/src/models/subscription_cidrs"; import { secureExpressApp } from "@pagopa/io-functions-commons/dist/src/utils/express"; import { wrapRequestHandler } from "@pagopa/io-functions-commons/dist/src/utils/request_middleware"; @@ -15,6 +20,7 @@ import { pipe } from "fp-ts/lib/function"; import { IConfig } from "../config"; import { TelemetryClient } from "../utils/applicationinsight"; import { CosmosHelper, CosmosPagedHelper } from "../utils/cosmos-helper"; +import { cosmosdbInstance } from "../utils/cosmos-legacy"; import { ServiceTopicDao } from "../utils/service-topic-dao"; import { applyRequestMiddelwares as applyCheckServiceDuplicationInternalRequestMiddelwares, @@ -32,6 +38,10 @@ import { applyRequestMiddelwares as applyEditServiceRequestMiddelwares, makeEditServiceHandler, } from "./controllers/edit-service"; +import { + applyRequestMiddelwares as applyGetServiceActivationRequestMiddlewares, + makeGetServiceActivationsHandler, +} from "./controllers/get-service-activation"; import { applyRequestMiddelwares as applyGetServiceHistoryRequestMiddelwares, makeGetServiceHistoryHandler, @@ -89,14 +99,23 @@ import { applyRequestMiddelwares as applyUploadServiceLogoRequestMiddelwares, makeUploadServiceLogoHandler, } from "./controllers/upload-service-logo"; +import { + applyRequestMiddelwares as applyUpsertServiceActivationRequestMiddlewares, + makeUpsertServiceActivationHandler, +} from "./controllers/upsert-service-activation"; const serviceLifecyclePath = "/services/:serviceId"; const servicePublicationPath = "/services/:serviceId/release"; +const serviceModel = new ServiceModel( + cosmosdbInstance.container(SERVICE_COLLECTION_NAME), +); + export interface WebServerDependencies { + activationsContainerClient: ContainerClient; apimService: ApimUtils.ApimService; + assetBlobService: BlobService; basePath: string; - blobService: BlobService; config: IConfig; fsmLifecycleClientCreator: ServiceLifecycle.FsmClientCreator; fsmPublicationClient: ServicePublication.FsmClient; @@ -110,9 +129,10 @@ export interface WebServerDependencies { // eslint-disable-next-line max-lines-per-function export const createWebServer = ({ + activationsContainerClient, apimService, + assetBlobService, basePath, - blobService, config, fsmLifecycleClientCreator, fsmPublicationClient, @@ -370,7 +390,7 @@ export const createWebServer = ({ pipe( makeUploadServiceLogoHandler({ apimService, - blobService, + assetBlobService, fsmLifecycleClientCreator, telemetryClient, }), @@ -378,6 +398,28 @@ export const createWebServer = ({ ), ); + router.post( + "/activations", + pipe( + makeGetServiceActivationsHandler({ + activationsContainerClient, + telemetryClient, + }), + applyGetServiceActivationRequestMiddlewares(serviceModel), + ), + ); + + router.put( + "/activations", + pipe( + makeUpsertServiceActivationHandler({ + activationsContainerClient, + telemetryClient, + }), + applyUpsertServiceActivationRequestMiddlewares(serviceModel), + ), + ); + // configure app const app = express(); secureExpressApp(app); diff --git a/packages/io-services-cms-models/openapi/services-cms-schemas.yaml b/packages/io-services-cms-models/openapi/services-cms-schemas.yaml index 62f2c229b..34ebb5732 100644 --- a/packages/io-services-cms-models/openapi/services-cms-schemas.yaml +++ b/packages/io-services-cms-models/openapi/services-cms-schemas.yaml @@ -349,3 +349,47 @@ ServiceTopic: required: - id - name +ActivationPayload: + type: object + properties: + status: + type: string + enum: + - ACTIVE + - INACTIVE + - PENDING + fiscal_code: + $ref: 'https://raw.githubusercontent.com/pagopa/io-services-cms/master/packages/io-services-cms-models/openapi/commons-schemas.yaml#/FiscalCode' + required: + - fiscal_code + - status +Activation: + type: object + properties: + service_id: + type: string + description: |- + The ID of the Service. Equals the subscriptionId of a registered + API user. + fiscal_code: + $ref: 'https://raw.githubusercontent.com/pagopa/io-services-cms/master/packages/io-services-cms-models/openapi/commons-schemas.yaml#/FiscalCode' + status: + type: string + enum: + - ACTIVE + - INACTIVE + - PENDING + modified_at: + $ref: 'https://raw.githubusercontent.com/pagopa/io-services-cms/master/packages/io-services-cms-models/openapi/commons-schemas.yaml#/Timestamp' + required: + - service_id + - fiscal_code + - status + - modified_at +FiscalCodePayload: + type: object + properties: + fiscal_code: + $ref: 'https://raw.githubusercontent.com/pagopa/io-services-cms/master/packages/io-services-cms-models/openapi/commons-schemas.yaml#/FiscalCode' + required: + - fiscal_code