diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 86fefd4c3e..97ddc15d09 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -137,12 +137,14 @@ jobs: --collect:"XPlat Code Coverage;Format=opencover;Include=**/*.cs;ExcludeByFile=**/*Tests.cs,**/Tests/**/*.cs,**/Program.cs,**/Model/**/*.cs,**/Set-up/**/*.cs,**/scripts/**/*.cs,**/HealthCheckFunction.cs,**/*Config.cs,**/bin/**/*.cs,**/obj/**/*.cs,**/Properties/**/*.cs,**/*.generated.cs,**/*.Designer.cs,**/*.g.cs,**/*.GlobalUsings.g.cs,**/*.AssemblyInfo.cs" \ --verbosity quiet - name: Upload test results as artifact + if: always() uses: actions/upload-artifact@v4 with: name: test-results-consolidated path: | TestResults/**/*.${{ inputs.unit_test_logger_format }} TestResults/**/coverage.opencover.xml + if-no-files-found: ignore test-unit: name: Unit tests @@ -189,12 +191,14 @@ jobs: --verbosity quiet done - name: Upload test results as artifact + if: always() uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.scope }} path: | TestResults/**/*.${{ inputs.unit_test_logger_format }} TestResults/**/coverage.opencover.xml + if-no-files-found: ignore aggregate-test-results: name: Aggregate results and report @@ -261,4 +265,4 @@ jobs: make test-lint - name: Save the linting result run: | - echo "Nothing to save" \ No newline at end of file + echo "Nothing to save" diff --git a/application/CohortManager/.env.example b/application/CohortManager/.env.example index a0b061c6b1..14d24528b5 100644 --- a/application/CohortManager/.env.example +++ b/application/CohortManager/.env.example @@ -36,3 +36,8 @@ ENDPOINT_BS_SELECT_UPDATE_BLOCK_FLAG="" # "http://localhost:7026/" SERVICENOW_CLIENT_ID= SERVICENOW_CLIENT_SECRET= SERVICENOW_REFRESH_TOKEN= + +# CAAS Manage Subscription (local/dev overrides) +# Optional overrides for CAAS mailbox IDs used by ManageCaasSubscription Subscribe stub +CAAS_SUBSCRIBE_TO_MAILBOX= +CAAS_SUBSCRIBE_FROM_MAILBOX= diff --git a/application/CohortManager/compose.core.yaml b/application/CohortManager/compose.core.yaml index 3b317604c6..535c9da19b 100644 --- a/application/CohortManager/compose.core.yaml +++ b/application/CohortManager/compose.core.yaml @@ -59,6 +59,8 @@ services: - ExceptionFunctionURL=http://create-exception:7070/api/CreateException - RetrievePdsDemographicURL=http://retrieve-pds-demographic:8082/api/RetrievePDSDemographic - UnsubscribeNemsSubscriptionUrl=http://manage-nems-subscription:9081/api/Unsubscribe + # CAAS manage subscription stub available at manage-caas-subscription when needed + # - UnsubscribeCaasSubscriptionUrl=http://manage-caas-subscription:9084/api/Unsubscribe - DemographicDataServiceURL=http://participant-demographic-data-service:7993/api/ParticipantDemographicDataService - ServiceBusConnectionString_client_internal=Endpoint=sb://service-bus;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true; - ParticipantManagementTopic=participant-management-topic @@ -124,6 +126,7 @@ services: - SendServiceNowMessageURL=http://servicenow-message-handler:9092/api/servicenow/send - ParticipantManagementURL=http://participant-management-data-service:7994/api/ParticipantManagementDataService - ManageNemsSubscriptionSubscribeURL=http://manage-nems-subscription:9081/api/Subscribe + # - ManageNemsSubscriptionSubscribeURL=http://manage-caas-subscription:9084/api/Subscribe - CohortDistributionTopic=cohort-distribution-topic update-blocked-flag: @@ -144,6 +147,8 @@ services: - ExceptionFunctionURL=http://create-exception:7070/api/CreateException - ManageNemsSubscriptionUnsubscribeURL=http://manage-nems-subscription:9081/api/Unsubscribe - ManageNemsSubscriptionSubscribeURL=http://manage-nems-subscription:9081/api/Subscribe + # - ManageNemsSubscriptionUnsubscribeURL=http://manage-caas-subscription:9084/api/Unsubscribe + # - ManageNemsSubscriptionSubscribeURL=http://manage-caas-subscription:9084/api/Subscribe - RetrievePdsDemographicURL=http://etrieve-pds-demographic:8082/api/RetrievePdsDemographic delete-participant: @@ -293,6 +298,31 @@ services: ports: - "9081:9081" + manage-caas-subscription: + container_name: manage-caas-subscription + image: cohort-manager-manage-caas-subscription + networks: [cohman-network] + profiles: [nems] + build: + context: ./src/Functions/ + dockerfile: DemographicServices/ManageCaasSubscription/Dockerfile + environment: + - ASPNETCORE_URLS=http://*:9084 + - FUNCTIONS_WORKER_RUNTIME=dotnet-isolated + - AzureWebJobsStorage=${AZURITE_CONNECTION_STRING} + - ExceptionFunctionURL=http://create-exception:7070/api/CreateException + - MeshApiBaseUrl=https://localhost:8700/messageexchange + - MeshCaasPassword=password + - MeshCaasSharedKey=TestKey + - MeshCaasKeyName= + - MeshCaasKeyPassword= + - IsStubbed=false + - CaasToMailbox=${CAAS_SUBSCRIBE_TO_MAILBOX:-CAAS_TO} + - CaasFromMailbox=${CAAS_SUBSCRIBE_FROM_MAILBOX:-CAAS_FROM} + - DtOsDatabaseConnectionString=Server=db,1433;Database=${DB_NAME};User Id=SA;Password=${PASSWORD};TrustServerCertificate=True + ports: + - "9084:9084" + durable-demographic-function: container_name: durable-demographic-function image: cohort-manager-durable-demographic-function diff --git a/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/Program.cs b/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/Program.cs index 3ec991369b..5c970c552b 100644 --- a/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/Program.cs +++ b/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/Program.cs @@ -10,7 +10,6 @@ using NHS.Screening.RetrieveMeshFile; using HealthChecks.Extensions; using Azure.Security.KeyVault.Secrets; -using NHS.CohortManager.CaasIntegrationService; var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile new file mode 100644 index 0000000000..5216eea448 --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile @@ -0,0 +1,29 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS base + +COPY ./Shared /Shared +WORKDIR /Shared + +RUN mkdir -p /home/site/wwwroot && \ + dotnet publish ./Common/Common.csproj --output /home/site/wwwroot && \ + dotnet publish ./Model/Model.csproj --output /home/site/wwwroot && \ + dotnet publish ./Data/Data.csproj --output /home/site/wwwroot && \ + dotnet publish ./Utilities/Utilities.csproj --output /home/site/wwwroot && \ + dotnet publish ./DataServices.Client/DataServices.Client.csproj --output /home/site/wwwroot && \ + dotnet publish ./DataServices.Core/DataServices.Core.csproj --output /home/site/wwwroot && \ + dotnet publish ./DataServices.Database/DataServices.Database.csproj --output /home/site/wwwroot + +FROM base AS function + +COPY ./DemographicServices/ManageCaasSubscription /src/dotnet-function-app +WORKDIR /src/dotnet-function-app + +RUN --mount=type=cache,target=/root/.nuget/packages \ + dotnet publish ./*.csproj --output /home/site/wwwroot + +# To enable ssh & remote debugging on app service change the base image to the one below +# FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0-appservice +FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0 +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true + +COPY --from=function ["/home/site/wwwroot", "/home/site/wwwroot"] diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/HealthCheckFunction.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/HealthCheckFunction.cs new file mode 100644 index 0000000000..69b48bac03 --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/HealthCheckFunction.cs @@ -0,0 +1,24 @@ +namespace NHS.CohortManager.DemographicServices; + +using System.Threading.Tasks; +using HealthChecks.Extensions; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +public class HealthCheckFunction +{ + private readonly HealthCheckService _healthCheckService; + + public HealthCheckFunction(HealthCheckService healthCheckService) + { + _healthCheckService = healthCheckService; + } + + [Function("health")] + public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) + { + return await HealthCheckServiceExtensions.CreateHealthCheckResponseAsync(req, _healthCheckService); + } +} + diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs new file mode 100644 index 0000000000..e16594e702 --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -0,0 +1,231 @@ +namespace NHS.CohortManager.DemographicServices; + +using System.Net; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Common; +using System.Collections.Specialized; +using System.Text; +using DataServices.Core; +using Model; +using NHS.CohortManager.DemographicServices; +using Common.Interfaces; + +/// +/// Azure Functions endpoints for managing CaaS subscriptions via MESH and data services. +/// +public class ManageCaasSubscription +{ + private readonly ILogger _logger; + private readonly ICreateResponse _createResponse; + private readonly ManageCaasSubscriptionConfig _config; + private readonly IMeshSendCaasSubscribe _meshSendCaasSubscribe; + private readonly IRequestHandler _requestHandler; + private readonly IDataServiceAccessor _nemsSubscriptionAccessor; + private readonly IMeshPoller _meshPoller; + private readonly IExceptionHandler _exceptionHandler; + + public ManageCaasSubscription( + ILogger logger, + ICreateResponse createResponse, + IOptions config, + IMeshSendCaasSubscribe meshSendCaasSubscribe, + IRequestHandler requestHandler, + IDataServiceAccessor nemsSubscriptionAccessor, + IMeshPoller meshPoller, + IExceptionHandler exceptionHandler) + { + _logger = logger; + _createResponse = createResponse; + _config = config.Value; + _meshSendCaasSubscribe = meshSendCaasSubscribe; + _requestHandler = requestHandler; + _nemsSubscriptionAccessor = nemsSubscriptionAccessor; + _meshPoller = meshPoller; + _exceptionHandler = exceptionHandler; + } + + + /// + /// Creates a new CaaS subscription for the given NHS number and persists a record. + /// + /// HTTP request containing an nhsNumber query parameter. + /// HTTP 200 on success, 400 for invalid input, or 500 on error. + [Function("Subscribe")] + public async Task Subscribe([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) + { + try + { + var nhsNumber = req.Query["nhsNumber"]; + if (!ValidationHelper.ValidateNHSNumber(nhsNumber!)) + { + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); + } + + var nhsNo = long.Parse(nhsNumber!); + var toMailbox = _config.CaasToMailbox!; + var fromMailbox = _config.CaasFromMailbox!; + var messageId = await _meshSendCaasSubscribe.SendSubscriptionRequest(nhsNo, toMailbox, fromMailbox); + + if (string.IsNullOrEmpty(messageId)) + { + var ex = new InvalidOperationException("Failed to send CAAS subscription via MESH"); + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsNo.ToString(), nameof(ManageCaasSubscription), "", $"to={toMailbox}"); + _logger.LogError("Failed to send CAAS subscription via MESH"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Failed to send CAAS subscription via MESH."); + } + + // Save a record to NEMS_SUBSCRIPTION table with source = MESH + var record = new NemsSubscription + { + SubscriptionId = messageId, + NhsNumber = nhsNo, + RecordInsertDateTime = DateTime.UtcNow, + SubscriptionSource = SubscriptionSource.MESH + }; + var saved = await _nemsSubscriptionAccessor.InsertSingle(record); + if (!saved) + { + var ex = new InvalidOperationException("Failed to save CAAS subscription record to database"); + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsNo.ToString(), nameof(ManageCaasSubscription), "", System.Text.Json.JsonSerializer.Serialize(record)); + _logger.LogError("Failed to write CAAS subscription record to database"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Failed to save subscription record."); + } + + if (_config.IsStubbed) + { + _logger.LogInformation("CAAS Subscribe forwarded to MESH stub. MessageId: {Msg}", messageId); + } + else + { + _logger.LogInformation("CAAS Subscribe sent to MESH. MessageId: {Msg}", messageId); + } + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, $"Subscription request accepted. MessageId: {messageId}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending CAAS subscribe request"); + try + { + string? rawNhs = req.Query["nhsNumber"]; + var nhsForLog = ValidationHelper.ValidateNHSNumber(rawNhs!) ? rawNhs! : string.Empty; + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsForLog, nameof(ManageCaasSubscription), "", string.Empty); + } + catch + { + // Swallow secondary errors to preserve primary failure path + } + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "An error occurred while sending the CAAS subscription request."); + } + } + + /// + /// Stub endpoint to remove a CaaS subscription for the given NHS number. + /// + /// HTTP request containing an nhsNumber query parameter. + /// HTTP 200 for the stub, or 400 for invalid input. + [Function("Unsubscribe")] + public async Task Unsubscribe([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) + { + var nhsNumber = req.Query["nhsNumber"]; + if (!ValidationHelper.ValidateNHSNumber(nhsNumber!)) + { + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); + } + + _logger.LogInformation("[CAAS-Stub] Unsubscribe called"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, "Stub: CAAS subscription would be removed."); + } + + /// + /// Checks subscription status for a given NHS number. + /// + /// HTTP request containing an nhsNumber query parameter. + /// HTTP 200 when an active subscription is found, 404 if not, or 400/500 on error. + [Function("CheckSubscriptionStatus")] + public async Task CheckSubscriptionStatus([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) + { + try + { + _logger.LogInformation("Received check subscription request"); + + string? nhsNumber = req.Query["nhsNumber"]; + + if (!ValidationHelper.ValidateNHSNumber(nhsNumber!)) + { + _logger.LogError("NHS number is required and must be valid format"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); + } + + var record = await _nemsSubscriptionAccessor.GetSingle(i => i.NhsNumber == long.Parse(nhsNumber!)); + string? subscriptionId = record?.SubscriptionId; + + if (string.IsNullOrEmpty(subscriptionId)) + { + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.NotFound, req, "No subscription found for this NHS number."); + } + + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, $"Active subscription found. Subscription ID: {subscriptionId}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking subscription status"); + try + { + string? rawNhs = req.Query["nhsNumber"]; + var nhsForLog = ValidationHelper.ValidateNHSNumber(rawNhs!) ? rawNhs! : string.Empty; + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsForLog, nameof(ManageCaasSubscription), "", string.Empty); + } + catch + { + // Swallow secondary errors to preserve primary failure path + } + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "An error occurred while checking subscription status."); + } + } + + /// + /// Pass-through data service endpoint for CRUD operations on the NEMS subscription data object. + /// + /// HTTP request containing payload and route parameters. + /// Optional key or route tail for the data service. + /// HTTP response from the underlying data service handler. + [Function("NemsSubscriptionDataService")] + public async Task NemsSubscriptionDataService([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", "put", "delete", Route = "NemsSubscriptionDataService/{*key}")] HttpRequestData req, string? key) + { + try + { + _logger.LogInformation("DataService Request Received Method: {Method}, DataObject {DataType} ", req.Method, typeof(NemsSubscription)); + var result = await _requestHandler.HandleRequest(req, key); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error has occurred in data service"); + try + { + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, string.Empty, nameof(ManageCaasSubscription), "", string.Empty); + } + catch + { + // Swallow secondary errors to preserve primary failure path + } + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "An error occurred while processing the data service request."); + } + } + + /// + /// Nightly timer trigger to validate the configured MESH mailbox via handshake. + /// + /// Timer trigger context. + [Function("PollMeshMailbox")] + public async Task RunAsync([TimerTrigger("59 23 * * *")] TimerInfo myTimer) + { + await _meshPoller.ExecuteHandshake(_config.CaasFromMailbox!); + } + + +} diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj new file mode 100644 index 0000000000..d58867ccbb --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj @@ -0,0 +1,35 @@ + + + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10} + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + + + + + diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs new file mode 100644 index 0000000000..58aede701d --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs @@ -0,0 +1,37 @@ +namespace NHS.CohortManager.DemographicServices; + +using System.ComponentModel.DataAnnotations; + +/// +/// Configuration for the ManageCaasSubscription function app and MESH connectivity. +/// +public class ManageCaasSubscriptionConfig +{ + /// Base URL for the MESH API. + [Required] + public required string MeshApiBaseUrl { get; set; } + /// Optional Azure Key Vault URL for certificate and secret retrieval. + public string? KeyVaultConnectionString { get; set; } + /// Bypass server certificate validation for local/dev purposes. + public bool BypassServerCertificateValidation { get; set; } = false; + /// Key Vault secret name or local path for CA certificates used to validate the MESH server. + public string? MeshCACertName { get; set; } + /// Key Vault certificate name or local path for the client certificate. + public string? MeshCaasKeyName { get; set; } + /// Passphrase for the client certificate, if required. + public string? MeshCaasKeyPassword { get; set; } + /// MESH mailbox password for client authentication. + public string? MeshCaasPassword { get; set; } + /// MESH shared key for HMAC authentication. + [Required] + public required string MeshCaasSharedKey { get; set; } + /// Destination mailbox for sending CAAS subscription messages. + [Required] + public required string CaasToMailbox { get; set; } + /// Source mailbox used for sending CAAS subscription messages. + [Required] + public required string CaasFromMailbox { get; set; } + + /// Controls whether shared implementations use stubbed behavior. + public bool IsStubbed { get; set; } +} diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs new file mode 100644 index 0000000000..06836de663 --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using HealthChecks.Extensions; +using Common; +using NHS.CohortManager.DemographicServices; +using DataServices.Database; +using DataServices.Core; + +var hostBuilder = new HostBuilder() + .ConfigureFunctionsWebApplication() + .AddConfiguration(out ManageCaasSubscriptionConfig? config) + .AddMeshMailboxes(new MeshConfig + { + MeshApiBaseUrl = config!.MeshApiBaseUrl, + KeyVaultConnectionString = config.KeyVaultConnectionString, + BypassServerCertificateValidation = config.BypassServerCertificateValidation, + MailboxConfigs = new List + { + new MailboxConfig + { + MailboxId = config.CaasFromMailbox, + MeshKeyName = config.MeshCaasKeyName!, + MeshKeyPassword = config.MeshCaasKeyPassword, + MeshPassword = config.MeshCaasPassword, + SharedKey = config.MeshCaasSharedKey + } + } + }) + .ConfigureServices(services => + { + services.AddSingleton(); + services.AddBasicHealthCheck("ManageCaasSubscription"); + if (config.IsStubbed) + { + services.AddSingleton(); + services.AddSingleton(); + } + else + { + services.AddScoped(); + services.AddScoped(); + } + }) + .AddDataServicesHandler() + .AddHttpClient() + .AddTelemetry() + .AddExceptionHandler(); + +// Log startup mode for visibility +var startupLoggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); +var startupLogger = startupLoggerFactory.CreateLogger("ManageCaasSubscription.Program"); +if (config!.IsStubbed) +{ + startupLogger.LogWarning("ManageCaasSubscription starting in STUBBED mode: using MeshSendCaasSubscribeStub and MeshPollerStub."); +} +else +{ + startupLogger.LogInformation("ManageCaasSubscription starting in LIVE mode: using real MESH services."); +} + +var host = hostBuilder.Build(); +await host.RunAsync(); diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageNemsSubscription/NemsSubscriptionManager.cs b/application/CohortManager/src/Functions/DemographicServices/ManageNemsSubscription/NemsSubscriptionManager.cs index 6b63bd561f..dae9521057 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageNemsSubscription/NemsSubscriptionManager.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageNemsSubscription/NemsSubscriptionManager.cs @@ -234,7 +234,8 @@ public async Task SaveSubscriptionInDatabase(string nhsNumber, string subs { SubscriptionId = subscriptionId, NhsNumber = Convert.ToInt64(nhsNumber), - RecordInsertDateTime = DateTime.UtcNow + RecordInsertDateTime = DateTime.UtcNow, + SubscriptionSource = SubscriptionSource.NEMS }; bool subscriptionCreated = await _nemsSubscriptionAccessor.InsertSingle(subscription); diff --git a/application/CohortManager/src/Functions/Functions.sln b/application/CohortManager/src/Functions/Functions.sln index 2406e88c6e..db7e46122f 100644 --- a/application/CohortManager/src/Functions/Functions.sln +++ b/application/CohortManager/src/Functions/Functions.sln @@ -187,8 +187,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UpdateBlockedFlagTests", ". EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManageNemsSubscription", "DemographicServices\ManageNemsSubscription\ManageNemsSubscription.csproj", "{800E7765-EC47-4343-A52C-16DF435B24D3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManageCaasSubscription", "DemographicServices\ManageCaasSubscription\ManageCaasSubscription.csproj", "{B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManageNemsSubscriptionTests", "..\..\..\..\tests\UnitTests\DemographicServicesTests\ManageNemsSubscriptionTests\ManageNemsSubscriptionTests.csproj", "{A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManageCaasSubscriptionTests", "..\..\..\..\tests\UnitTests\DemographicServicesTests\ManageCaasSubscriptionTests\ManageCaasSubscriptionTests.csproj", "{C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReferenceDataService", "screeningDataServices\ReferenceDataService\ReferenceDataService.csproj", "{8DFEDCFA-5A62-461E-94D3-3DA9609F26BB}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProcessNemsUpdate", "NemsSubscriptionService\ProcessNemsUpdate\ProcessNemsUpdate.csproj", "{1538902B-C11F-492B-86A6-86A0DB72C761}" @@ -235,6 +239,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PdsProcessorTests", "..\..\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlobStorageHelperTests", "..\..\..\..\tests\UnitTests\SharedTests\BlobStorageHelperTests\BlobStorageHelperTests.csproj", "{BFA68329-98DD-4039-B009-C6DF23146765}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeshCaaSSubscribeIntegrationTests", "..\..\..\..\tests\integration-tests\MeshCaaSSubscribeIntegrationTests\MeshCaaSSubscribeIntegrationTests.csproj", "{2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DemographicServicesTests", "DemographicServicesTests", "{3F84AC12-B761-25DE-181D-4BFB8336C214}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1145,6 +1153,18 @@ Global {800E7765-EC47-4343-A52C-16DF435B24D3}.Release|x64.Build.0 = Release|Any CPU {800E7765-EC47-4343-A52C-16DF435B24D3}.Release|x86.ActiveCfg = Release|Any CPU {800E7765-EC47-4343-A52C-16DF435B24D3}.Release|x86.Build.0 = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|x64.ActiveCfg = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|x64.Build.0 = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|x86.ActiveCfg = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|x86.Build.0 = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|Any CPU.Build.0 = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|x64.ActiveCfg = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|x64.Build.0 = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|x86.ActiveCfg = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|x86.Build.0 = Release|Any CPU {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Debug|Any CPU.Build.0 = Debug|Any CPU {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1157,6 +1177,18 @@ Global {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Release|x64.Build.0 = Release|Any CPU {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Release|x86.ActiveCfg = Release|Any CPU {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Release|x86.Build.0 = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|x64.Build.0 = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|x86.Build.0 = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|Any CPU.Build.0 = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|x64.ActiveCfg = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|x64.Build.0 = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|x86.ActiveCfg = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|x86.Build.0 = Release|Any CPU {8DFEDCFA-5A62-461E-94D3-3DA9609F26BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8DFEDCFA-5A62-461E-94D3-3DA9609F26BB}.Debug|Any CPU.Build.0 = Debug|Any CPU {8DFEDCFA-5A62-461E-94D3-3DA9609F26BB}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1193,6 +1225,18 @@ Global {0F8F78E8-9DC7-4BF7-8BA8-E56A79615434}.Release|x64.Build.0 = Release|Any CPU {0F8F78E8-9DC7-4BF7-8BA8-E56A79615434}.Release|x86.ActiveCfg = Release|Any CPU {0F8F78E8-9DC7-4BF7-8BA8-E56A79615434}.Release|x86.Build.0 = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x64.Build.0 = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x86.ActiveCfg = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x86.Build.0 = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|Any CPU.Build.0 = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x64.ActiveCfg = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x64.Build.0 = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x86.ActiveCfg = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x86.Build.0 = Release|Any CPU {33C49482-E216-4170-A2B9-00C816206566}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {33C49482-E216-4170-A2B9-00C816206566}.Debug|Any CPU.Build.0 = Debug|Any CPU {33C49482-E216-4170-A2B9-00C816206566}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1385,18 +1429,18 @@ Global {BFA68329-98DD-4039-B009-C6DF23146765}.Release|x64.Build.0 = Release|Any CPU {BFA68329-98DD-4039-B009-C6DF23146765}.Release|x86.ActiveCfg = Release|Any CPU {BFA68329-98DD-4039-B009-C6DF23146765}.Release|x86.Build.0 = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x64.ActiveCfg = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x64.Build.0 = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x86.ActiveCfg = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x86.Build.0 = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|Any CPU.Build.0 = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x64.ActiveCfg = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x64.Build.0 = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x86.ActiveCfg = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x86.Build.0 = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|x64.ActiveCfg = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|x64.Build.0 = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|x86.ActiveCfg = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|x86.Build.0 = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|Any CPU.Build.0 = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x64.ActiveCfg = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x64.Build.0 = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x86.ActiveCfg = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1448,17 +1492,20 @@ Global {AF3A5F34-77F2-7915-5806-A9586C50EB46} = {2F646680-91A4-F107-74F4-9BF3126A0F16} {A4EBDA0F-060E-4454-AD01-D56ECC2D1BBE} = {AF3A5F34-77F2-7915-5806-A9586C50EB46} {800E7765-EC47-4343-A52C-16DF435B24D3} = {0DDB5B2A-AD97-BFD6-A081-43D49FE0B20F} + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10} = {0DDB5B2A-AD97-BFD6-A081-43D49FE0B20F} + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E} = {2F646680-91A4-F107-74F4-9BF3126A0F16} {8DFEDCFA-5A62-461E-94D3-3DA9609F26BB} = {006A9B93-41E9-9D6B-03D8-80B6AB48B9E5} {1538902B-C11F-492B-86A6-86A0DB72C761} = {81415B60-C1D4-887B-0030-6F6B04DE9059} + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B} = {CA13ADEB-51D6-44BD-8721-C858D693B481} {6F35BE1C-121E-5D91-98E7-83CAF153EF2E} = {19500E0D-AAAB-6F02-E24F-82619ACA2290} {87F24206-BDE4-471F-B031-791308443CEE} = {72FBAE8E-5F64-9545-D6D4-8F59D0E91151} {DF48630C-8B25-433F-B5CA-C838CB34D0A6} = {006A9B93-41E9-9D6B-03D8-80B6AB48B9E5} {5641FB51-B7C1-445A-A210-EB985E05CC9E} = {CA13ADEB-51D6-44BD-8721-C858D693B481} - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B} = {CA13ADEB-51D6-44BD-8721-C858D693B481} {4A786537-26D6-4964-BB53-C9B03A0CE5D6} = {19500E0D-AAAB-6F02-E24F-82619ACA2290} {52C72C2E-9A76-4ECF-A210-C3F7C584C193} = {19500E0D-AAAB-6F02-E24F-82619ACA2290} {4BD680A2-1ACB-7D6B-B2FD-8EBE9AEB5050} = {E8E33C5F-F9FB-3ACA-2B58-298ED48517C1} {59CBDBE5-29BE-F38C-80E6-40843F2F8AF6} = {E8E33C5F-F9FB-3ACA-2B58-298ED48517C1} {392B3D99-C5C5-DB9F-4DCA-F389E679C7C0} = {5555D2A1-8C8F-5B64-9F84-08EFE0FC7CD8} + {3F84AC12-B761-25DE-181D-4BFB8336C214} = {2F646680-91A4-F107-74F4-9BF3126A0F16} EndGlobalSection EndGlobal diff --git a/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/CertificateHelper.cs b/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/CertificateHelper.cs deleted file mode 100644 index b44048dcb3..0000000000 --- a/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/CertificateHelper.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace NHS.CohortManager.CaasIntegrationService; - -using System.Security.Cryptography.X509Certificates; - -public static class CertificateHelper -{ - public static X509Certificate2Collection GetCertificatesFromString(string certificatesString) - { - X509Certificate2Collection certs = []; - - X509Certificate2[] pemCerts = certificatesString - .Split("-----END CERTIFICATE-----", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(pem => pem + "\n-----END CERTIFICATE-----") - .Select(pem => - { - var base64 = pem - .Replace("-----BEGIN CERTIFICATE-----", "") - .Replace("-----END CERTIFICATE-----", "") - .Replace("\n", "") - .Replace("\r", "") - .Trim(); - - return new X509Certificate2(Convert.FromBase64String(base64)); - }) - .ToArray(); - - certs.AddRange(pemCerts); - - return certs; - } -} \ No newline at end of file diff --git a/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/Program.cs b/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/Program.cs index 75eee8eecc..6573e70f7d 100644 --- a/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/Program.cs +++ b/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/Program.cs @@ -10,7 +10,6 @@ using NHS.Screening.NemsMeshRetrieval; using HealthChecks.Extensions; using Azure.Security.KeyVault.Secrets; -using NHS.CohortManager.CaasIntegrationService; var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); @@ -20,7 +19,7 @@ { var host = new HostBuilder(); - X509Certificate2 cohortManagerPrivateKey = null; + X509Certificate2 cohortManagerPrivateKey = null!; X509Certificate2Collection meshCerts = []; host.AddConfiguration(out NemsMeshRetrievalConfig config); @@ -83,4 +82,3 @@ logger.LogCritical(ex, "Failed to start up Function"); } - diff --git a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs new file mode 100644 index 0000000000..c8cad4197c --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs @@ -0,0 +1,103 @@ +namespace Common; + +using System.Security.Cryptography.X509Certificates; +using Azure.Identity; +using Azure.Security.KeyVault.Certificates; +using Azure.Security.KeyVault.Secrets; +using Hl7.Fhir.Model.CdsHooks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NHS.MESH.Client; +using NHS.MESH.Client.Configuration; +/// +/// Service registration helpers for configuring MESH clients and mailboxes. +/// +public static class MeshMailboxExtension +{ + /// + /// Registers the MESH client and configured mailboxes for dependency injection. + /// + /// The host builder to extend. + /// Configuration for MESH and mailboxes. + /// The original host builder. + public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshConfig config) + { + ILoggerFactory factory = LoggerFactory.Create(builder => + { + builder.AddConsole(); + builder.AddApplicationInsights(); + }); + var logger = factory.CreateLogger("MeshMailboxExtension"); + + hostBuilder.ConfigureServices(async services => + { + var meshClientBuilder = services.AddMeshClient(_ => + { + _.MeshApiBaseUrl = config.MeshApiBaseUrl; + _.BypassServerCertificateValidation = config.BypassServerCertificateValidation; + }); + + foreach (var mailbox in config.MailboxConfigs) + { + var cert = await GetCertificate(logger, mailbox.MeshKeyName, mailbox.MeshKeyPassword, config.KeyVaultConnectionString); + var serverSideCerts = await GetCACertificates(logger, config.MeshCACertName, config.KeyVaultConnectionString); + meshClientBuilder.AddMailbox(mailbox.MailboxId, new MailboxConfiguration + { + Password = mailbox.MeshPassword, + SharedKey = mailbox.SharedKey, + Cert = cert, + serverSideCertCollection = serverSideCerts + }); + } + + meshClientBuilder.Build(); + + }); + + return hostBuilder; + } + + private static async Task GetCertificate(ILogger logger, string? meshKeyName, string? meshKeyPassphrase, string? keyVaultConnectionString) + { + if (!string.IsNullOrEmpty(keyVaultConnectionString)) + { + logger.LogInformation("Pulling Mesh Certificate from KeyVault"); + var certClient = new CertificateClient(vaultUri: new Uri(keyVaultConnectionString), credential: new ManagedIdentityCredential()); + if (string.IsNullOrWhiteSpace(meshKeyName)) return null; + var certificate = await certClient.DownloadCertificateAsync(meshKeyName); + return certificate.Value; + } + + if (!string.IsNullOrWhiteSpace(meshKeyName) && Path.Exists(meshKeyName)) + { + logger.LogInformation("Pulling Mesh Certificate from local File"); + return new X509Certificate2(meshKeyName!, meshKeyPassphrase); + } + return null; + } + + + /// + /// Retrieves server-side CA certificates from Key Vault or a local file path. + /// + /// Key Vault secret name or file path containing PEM certificates. + /// Optional Key Vault URI. + /// A certificate collection when available; otherwise null. + public static async Task GetCACertificates(ILogger logger, string? meshCertName, string? keyVaultConnectionString) + { + if (!string.IsNullOrEmpty(keyVaultConnectionString)) + { + var secretClient = new SecretClient(vaultUri: new Uri(keyVaultConnectionString), credential: new ManagedIdentityCredential()); + if (string.IsNullOrWhiteSpace(meshCertName)) return null; + var secret = await secretClient.GetSecretAsync(meshCertName); + string base64Cert = secret.Value.Value; + return CertificateHelper.GetCertificatesFromString(base64Cert); + } + if (!string.IsNullOrWhiteSpace(meshCertName) && Path.Exists(meshCertName)) + { + string certsString = await File.ReadAllTextAsync(meshCertName); + return CertificateHelper.GetCertificatesFromString(certsString); + } + return null; + } +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshPoller.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshPoller.cs new file mode 100644 index 0000000000..7e62ab5bd2 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshPoller.cs @@ -0,0 +1,19 @@ +namespace Common; + +public interface IMeshPoller +{ + /// + /// Executes a mesh handshake to the given mailboxId + /// This should be executed every 24 hours for every mesh mailbox + /// + /// + /// boolean, True if successful + Task ExecuteHandshake(string mailboxId); + /// + /// Will check against some state object if the handshake has not been executed in the past 23h55 mins + /// + /// + /// + /// true is handshake should be executed, false if not + Task ShouldExecuteHandshake(string mailboxId, string configFileName); +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs new file mode 100644 index 0000000000..e183288f0e --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs @@ -0,0 +1,16 @@ +namespace Common; + +/// +/// Contract for sending CAAS subscription requests via MESH. +/// +public interface IMeshSendCaasSubscribe +{ + /// + /// Sends a CAAS subscription request for the given NHS number. + /// + /// The patient NHS number. + /// Destination MESH mailbox ID. + /// Source MESH mailbox ID. + /// The MESH message ID on success; otherwise null. + Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox); +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs new file mode 100644 index 0000000000..23374d838d --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs @@ -0,0 +1,36 @@ +namespace Common; + +/// +/// Root configuration for the shared MESH client and associated mailboxes. +/// +public class MeshConfig +{ + /// Azure Key Vault URI for certificate and secret retrieval. + public string? KeyVaultConnectionString { get; set; } + /// Key Vault secret name or local file path containing server CA certificates. + public string? MeshCACertName { get; set; } + /// Bypass server certificate validation. Use only for local development. + public bool BypassServerCertificateValidation { get; set; } = false; + /// Base URL for the MESH API. + public required string MeshApiBaseUrl { get; set; } + /// Configured mailboxes to register with the client. + public required List MailboxConfigs { get; set; } +} + +/// +/// Configuration for a specific MESH mailbox. +/// +public class MailboxConfig +{ + /// The MESH mailbox identifier. + public required string MailboxId { get; set; } + /// Key Vault certificate name or local path for the client certificate. + public string? MeshKeyName { get; set; } + /// Passphrase for the client certificate, if required. + public string? MeshKeyPassword { get; set; } + /// Password for the mailbox account. + public string? MeshPassword { get; set; } + /// Shared key used for HMAC authentication. + public required string SharedKey { get; set; } + +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs new file mode 100644 index 0000000000..f0f4cf1a17 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs @@ -0,0 +1,54 @@ +namespace Common; + +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Model; +using NHS.MESH.Client.Contracts.Services; + +/// +/// Coordinates periodic MESH mailbox operations such as handshake validation. +/// +public class MeshPoller : IMeshPoller +{ + private readonly ILogger _logger; + private readonly IMeshOperationService _meshOperationService; + + public MeshPoller(ILogger logger, IMeshOperationService meshOperationService) + { + _logger = logger; + _meshOperationService = meshOperationService; + } + + /// + /// Determines if a handshake should be executed based on persisted state. + /// + /// The mailbox to validate. + /// A state/configuration file identifier. + /// True if a handshake should run; otherwise false. + public async Task ShouldExecuteHandshake(string mailboxId, string configFileName) + { + await Task.CompletedTask; + throw new NotImplementedException("ShouldExecuteHandshake is not yet implemented"); + } + + /// + /// Executes a MESH handshake for the provided mailbox. + /// + /// The mailbox to validate. + /// True when successful; otherwise false. + public async Task ExecuteHandshake(string mailboxId) + { + var meshValidationResponse = await _meshOperationService.MeshHandshakeAsync(mailboxId); + + if (!meshValidationResponse.IsSuccessful) + { + _logger.LogError("Error While handshaking with MESH. ErrorCode: {ErrorCode}, ErrorDescription: {ErrorDescription}", meshValidationResponse.Error?.ErrorCode, meshValidationResponse.Error?.ErrorDescription); + return false; + } + + return true; + } + +} + + diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPollerStub.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPollerStub.cs new file mode 100644 index 0000000000..e6a02b0474 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPollerStub.cs @@ -0,0 +1,14 @@ +namespace Common; + +public class MeshPollerStub : IMeshPoller +{ + public Task ExecuteHandshake(string mailboxId) + { + return Task.FromResult(true); + } + + public Task ShouldExecuteHandshake(string mailboxId, string configFileName) + { + return Task.FromResult(false); + } +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs new file mode 100644 index 0000000000..ccc32ee16f --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs @@ -0,0 +1,80 @@ +namespace Common; + +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NHS.MESH.Client.Contracts.Services; +using NHS.MESH.Client.Models; +using ParquetSharp; +using ParquetSharp.IO; + +/// +/// Sends CAAS subscription requests via the MESH outbox service. +/// +public class MeshSendCaasSubscribe : IMeshSendCaasSubscribe +{ + private readonly ILogger _logger; + private readonly IMeshOutboxService _meshOutboxService; + private readonly MeshSendCaasSubscribeConfig _config; + public MeshSendCaasSubscribe(ILogger logger, IMeshOutboxService meshOutboxService, IOptions config) + { + _logger = logger; + _meshOutboxService = meshOutboxService; + _config = config.Value; + } + + /// + /// Sends a CAAS subscription request for a given NHS number. + /// + /// The patient NHS number. + /// Destination MESH mailbox ID. + /// Source MESH mailbox ID. + /// The MESH message ID on success; otherwise null. + public async Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox) + { + + var content = CreateParquetFile(nhsNumber); + + FileAttachment file = new FileAttachment + { + FileName = "CaaSSubscribe.parquet", + Content = content, + ContentType = "application/octet-stream" + }; + + var result = await _meshOutboxService.SendCompressedMessageAsync(fromMailbox, toMailbox, _config.SendCaasWorkflowId, file); + if (!result.IsSuccessful) + { + _logger.LogError( + "Could not send MESH message. Error Code: {ErrorCode}, Error Description: {ErrorDescription}", + result.Error?.ErrorCode, + result.Error?.ErrorDescription); + return null; + } + + return result.Response.MessageId; + } + + private static byte[] CreateParquetFile(long nhsNumber) + { + var columns = new Column[] + { + new Column("nhs_number"), + }; + long[] nhsNumberList = { nhsNumber }; + + using var stream = new MemoryStream(); + using var writer = new ManagedOutputStream(stream); + using (var file = new ParquetFileWriter(writer, columns)) + { + using var rowGroup = file.AppendRowGroup(); + using (var nhsNumberColumn = rowGroup.NextColumn().LogicalWriter()) + { + nhsNumberColumn.WriteBatch(nhsNumberList); + } + } + + return stream.ToArray(); + + } +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs new file mode 100644 index 0000000000..b285bbd0d9 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs @@ -0,0 +1,10 @@ +namespace Common; + +/// +/// Settings for sending CAAS subscribe messages over MESH. +/// +public class MeshSendCaasSubscribeConfig +{ + /// The workflow identifier to apply to outbound CAAS messages. + public required string SendCaasWorkflowId { get; set; } +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs new file mode 100644 index 0000000000..ab20fec508 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs @@ -0,0 +1,12 @@ +namespace Common; + +using System.Threading.Tasks; + +public class MeshSendCaasSubscribeStub : IMeshSendCaasSubscribe +{ + public async Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox) + { + await Task.CompletedTask; + return $"STUB_{Guid.NewGuid():N}"; + } +} diff --git a/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/CertificateHelper.cs b/application/CohortManager/src/Functions/Shared/Common/Utilities/CertificateHelper.cs similarity index 68% rename from application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/CertificateHelper.cs rename to application/CohortManager/src/Functions/Shared/Common/Utilities/CertificateHelper.cs index b44048dcb3..865035265f 100644 --- a/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/CertificateHelper.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Utilities/CertificateHelper.cs @@ -1,9 +1,17 @@ -namespace NHS.CohortManager.CaasIntegrationService; +namespace Common; using System.Security.Cryptography.X509Certificates; +/// +/// Helpers for parsing certificates from PEM content. +/// public static class CertificateHelper { + /// + /// Parses one or more PEM-encoded certificates from a string into a collection. + /// + /// A string containing one or more concatenated PEM certificates. + /// An containing parsed certificates. public static X509Certificate2Collection GetCertificatesFromString(string certificatesString) { X509Certificate2Collection certs = []; @@ -23,9 +31,9 @@ public static X509Certificate2Collection GetCertificatesFromString(string certif return new X509Certificate2(Convert.FromBase64String(base64)); }) .ToArray(); - + certs.AddRange(pemCerts); - + return certs; } -} \ No newline at end of file +} diff --git a/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.Designer.cs b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.Designer.cs new file mode 100644 index 0000000000..d780f4da1e --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.Designer.cs @@ -0,0 +1,1064 @@ +// +using System; +using DataServices.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DataServices.Migrations.Migrations +{ + [DbContext(typeof(DataServicesContext))] + [Migration("20250829180608_add_subscription_source_to_nems")] + partial class add_subscription_source_to_nems + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Model.BsSelectGpPractice", b => + { + b.Property("GpPracticeCode") + .HasColumnType("nvarchar(450)") + .HasColumnName("GP_PRACTICE_CODE"); + + b.Property("AuditCreatedTimeStamp") + .HasColumnType("datetime") + .HasColumnName("AUDIT_CREATED_TIMESTAMP"); + + b.Property("AuditId") + .HasColumnType("decimal(18,2)") + .HasColumnName("AUDIT_ID"); + + b.Property("AuditLastUpdatedTimeStamp") + .HasColumnType("datetime") + .HasColumnName("AUDIT_LAST_MODIFIED_TIMESTAMP"); + + b.Property("AuditText") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("AUDIT_TEXT"); + + b.Property("BsoCode") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("BSO"); + + b.Property("CountryCategory") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("COUNTRY_CATEGORY"); + + b.HasKey("GpPracticeCode"); + + b.ToTable("BS_SELECT_GP_PRACTICE_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.BsSelectOutCode", b => + { + b.Property("Outcode") + .HasColumnType("nvarchar(450)") + .HasColumnName("OUTCODE"); + + b.Property("AuditCreatedTimeStamp") + .HasColumnType("datetime") + .HasColumnName("AUDIT_CREATED_TIMESTAMP"); + + b.Property("AuditId") + .HasColumnType("decimal(18,2)") + .HasColumnName("AUDIT_ID"); + + b.Property("AuditLastModifiedTimeStamp") + .HasColumnType("datetime") + .HasColumnName("AUDIT_LAST_MODIFIED_TIMESTAMP"); + + b.Property("AuditText") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("AUDIT_TEXT"); + + b.Property("BSO") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("BSO"); + + b.HasKey("Outcode"); + + b.ToTable("BS_SELECT_OUTCODE_MAPPING_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.BsSelectRequestAudit", b => + { + b.Property("RequestId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("REQUEST_ID"); + + b.Property("CreatedDateTime") + .HasColumnType("datetime") + .HasColumnName("CREATED_DATETIME"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("STATUS_CODE"); + + b.HasKey("RequestId"); + + b.ToTable("BS_SELECT_REQUEST_AUDIT", "dbo"); + }); + + modelBuilder.Entity("Model.BsoOrganisation", b => + { + b.Property("BsoOrganisationId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("BSO_ORGANISATION_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("BsoOrganisationId")); + + b.Property("AddressLine1") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("ADDRESS_LINE_1"); + + b.Property("AddressLine2") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("ADDRESS_LINE_2"); + + b.Property("AddressLine3") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("ADDRESS_LINE_3"); + + b.Property("AddressLine4") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("ADDRESS_LINE_4"); + + b.Property("AddressLine5") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("ADDRESS_LINE_5"); + + b.Property("AdminEmailAddress") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADMIN_EMAIL_ADDRESS"); + + b.Property("AutoBatchLastRun") + .HasColumnType("datetime2") + .HasColumnName("AUTO_BATCH_LAST_RUN"); + + b.Property("AutoBatchMaxDateTimeProcessed") + .HasColumnType("datetime2") + .HasColumnName("AUTO_BATCH_MAX_DATE_TIME_PROCESSED"); + + b.Property("BsoOrganisationCode") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)") + .HasColumnName("BSO_ORGANISATION_CODE"); + + b.Property("BsoOrganisationName") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)") + .HasColumnName("BSO_ORGANISATION_NAME"); + + b.Property("BsoRecallInterval") + .HasColumnType("tinyint") + .HasColumnName("BSO_RECALL_INTERVAL"); + + b.Property("BsoRegionId") + .HasColumnType("int") + .HasColumnName("BSO_REGION_ID"); + + b.Property("EmailAddress") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("EMAIL_ADDRESS"); + + b.Property("Extension") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("EXTENSION"); + + b.Property("FailSafeDateOfMonth") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_DATE_OF_MONTH"); + + b.Property("FailSafeLastRun") + .HasColumnType("datetime2") + .HasColumnName("FAILSAFE_LAST_RUN"); + + b.Property("FailSafeMaxAgeMonths") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_MAX_AGE_MONTHS"); + + b.Property("FailSafeMaxAgeYears") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_MAX_AGE_YEARS"); + + b.Property("FailSafeMinAgeMonths") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_MIN_AGE_MONTHS"); + + b.Property("FailSafeMinAgeYears") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_MIN_AGE_YEARS"); + + b.Property("FailSafeMonths") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_MONTHS"); + + b.Property("FaxNumber") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("FAX_NUMBER"); + + b.Property("FoaMaxOffset") + .HasColumnType("tinyint") + .HasColumnName("FOA_MAX_OFFSET"); + + b.Property("IepDetails") + .HasColumnType("nvarchar(max)") + .HasColumnName("IEP_DETAILS"); + + b.Property("IgnoreEarlyRecall") + .HasColumnType("bit") + .HasColumnName("IGNORE_EARLY_RECALL"); + + b.Property("IgnoreGPReferrals") + .HasColumnType("bit") + .HasColumnName("IGNORE_GP_REFERRALS"); + + b.Property("IgnoreSelfReferrals") + .HasColumnType("bit") + .HasColumnName("IGNORE_SELF_REFERRALS"); + + b.Property("InviteListSequenceNumber") + .HasColumnType("int") + .HasColumnName("INVITE_LIST_SEQUENCE_NUMBER"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("IS_ACTIVE"); + + b.Property("IsAgex") + .HasColumnType("bit") + .HasColumnName("IS_AGEX"); + + b.Property("IsAgexActive") + .HasColumnType("bit") + .HasColumnName("IS_AGEX_ACTIVE"); + + b.Property("LinkCode") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("LINK_CODE"); + + b.Property("LowerAgeRange") + .HasColumnType("tinyint") + .HasColumnName("LOWER_AGE_RANGE"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)") + .HasColumnName("NOTES"); + + b.Property("OutgoingTransferNumber") + .HasColumnType("int") + .HasColumnName("OUTGOING_TRANSFER_NUMBER"); + + b.Property("PostCode") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasColumnName("POSTCODE"); + + b.Property("RispRecallInterval") + .HasColumnType("tinyint") + .HasColumnName("RISP_RECALL_INTERVAL"); + + b.Property("RlpDateEnabled") + .HasColumnType("datetime2") + .HasColumnName("RLP_DATE_ENABLED"); + + b.Property("SafetyPeriod") + .HasColumnType("tinyint") + .HasColumnName("SAFETY_PERIOD"); + + b.Property("TelephoneNumber") + .HasMaxLength(18) + .HasColumnType("nvarchar(18)") + .HasColumnName("TELEPHONE_NUMBER"); + + b.Property("TransactionAppDateTime") + .HasColumnType("datetime2") + .HasColumnName("TRANSACTION_APP_DATE_TIME"); + + b.Property("TransactionDbDateTime") + .HasColumnType("datetime2") + .HasColumnName("TRANSACTION_DB_DATE_TIME"); + + b.Property("TransactionId") + .HasColumnType("int") + .HasColumnName("TRANSACTION_ID"); + + b.Property("TransactionUserOrgRoleId") + .HasColumnType("int") + .HasColumnName("TRANSACTION_USER_ORG_ROLE_ID"); + + b.Property("UpperAgeRange") + .HasColumnType("tinyint") + .HasColumnName("UPPER_AGE_RANGE"); + + b.HasKey("BsoOrganisationId"); + + b.ToTable("BSO_ORGANISATIONS", "dbo"); + }); + + modelBuilder.Entity("Model.CohortDistribution", b => + { + b.Property("CohortDistributionId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("BS_COHORT_DISTRIBUTION_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CohortDistributionId")); + + b.Property("AddressLine1") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADDRESS_LINE_1"); + + b.Property("AddressLine2") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADDRESS_LINE_2"); + + b.Property("AddressLine3") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADDRESS_LINE_3"); + + b.Property("AddressLine4") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADDRESS_LINE_4"); + + b.Property("AddressLine5") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADDRESS_LINE_5"); + + b.Property("CurrentPosting") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("CURRENT_POSTING"); + + b.Property("CurrentPostingFromDt") + .HasColumnType("datetime") + .HasColumnName("CURRENT_POSTING_FROM_DT"); + + b.Property("DateOfBirth") + .HasColumnType("datetime") + .HasColumnName("DATE_OF_BIRTH"); + + b.Property("DateOfDeath") + .HasColumnType("datetime") + .HasColumnName("DATE_OF_DEATH"); + + b.Property("EmailAddressHome") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("EMAIL_ADDRESS_HOME"); + + b.Property("EmailAddressHomeFromDt") + .HasColumnType("datetime") + .HasColumnName("EMAIL_ADDRESS_HOME_FROM_DT"); + + b.Property("FamilyName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("FAMILY_NAME"); + + b.Property("Gender") + .HasColumnType("smallint") + .HasColumnName("GENDER"); + + b.Property("GivenName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("GIVEN_NAME"); + + b.Property("InterpreterRequired") + .HasColumnType("smallint") + .HasColumnName("INTERPRETER_REQUIRED"); + + b.Property("IsExtracted") + .HasColumnType("smallint") + .HasColumnName("IS_EXTRACTED"); + + b.Property("NHSNumber") + .HasColumnType("bigint") + .HasColumnName("NHS_NUMBER"); + + b.Property("NamePrefix") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("NAME_PREFIX"); + + b.Property("OtherGivenName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("OTHER_GIVEN_NAME"); + + b.Property("ParticipantId") + .HasColumnType("bigint") + .HasColumnName("PARTICIPANT_ID"); + + b.Property("PostCode") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("POST_CODE"); + + b.Property("PreferredLanguage") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("PREFERRED_LANGUAGE"); + + b.Property("PreviousFamilyName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("PREVIOUS_FAMILY_NAME"); + + b.Property("PrimaryCareProvider") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("PRIMARY_CARE_PROVIDER"); + + b.Property("PrimaryCareProviderDate") + .HasColumnType("datetime") + .HasColumnName("PRIMARY_CARE_PROVIDER_FROM_DT"); + + b.Property("ReasonForRemoval") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("REASON_FOR_REMOVAL"); + + b.Property("ReasonForRemovalDate") + .HasColumnType("datetime") + .HasColumnName("REASON_FOR_REMOVAL_FROM_DT"); + + b.Property("RecordInsertDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_INSERT_DATETIME"); + + b.Property("RecordUpdateDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATE_DATETIME"); + + b.Property("RequestId") + .HasColumnType("uniqueidentifier") + .HasColumnName("REQUEST_ID"); + + b.Property("SupersededNHSNumber") + .HasColumnType("bigint") + .HasColumnName("SUPERSEDED_NHS_NUMBER"); + + b.Property("TelephoneNumberHome") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("TELEPHONE_NUMBER_HOME"); + + b.Property("TelephoneNumberHomeFromDt") + .HasColumnType("datetime") + .HasColumnName("TELEPHONE_NUMBER_HOME_FROM_DT"); + + b.Property("TelephoneNumberMob") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("TELEPHONE_NUMBER_MOB"); + + b.Property("TelephoneNumberMobFromDt") + .HasColumnType("datetime") + .HasColumnName("TELEPHONE_NUMBER_MOB_FROM_DT"); + + b.Property("UsualAddressFromDt") + .HasColumnType("datetime") + .HasColumnName("USUAL_ADDRESS_FROM_DT"); + + b.HasKey("CohortDistributionId"); + + b.HasIndex(new[] { "IsExtracted", "RequestId" }, "IX_BSCOHORT_IS_EXTACTED_REQUESTID"); + + b.HasIndex(new[] { "NHSNumber" }, "IX_BS_COHORT_DISTRIBUTION_NHSNUMBER"); + + b.ToTable("BS_COHORT_DISTRIBUTION", "dbo"); + }); + + modelBuilder.Entity("Model.CurrentPosting", b => + { + b.Property("Posting") + .HasColumnType("nvarchar(450)") + .HasColumnName("POSTING"); + + b.Property("InUse") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("IN_USE"); + + b.Property("IncludedInCohort") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("INCLUDED_IN_COHORT"); + + b.Property("PostingCategory") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("POSTING_CATEGORY"); + + b.HasKey("Posting"); + + b.ToTable("CURRENT_POSTING_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.ExceptionManagement", b => + { + b.Property("ExceptionId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("EXCEPTION_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ExceptionId")); + + b.Property("Category") + .HasColumnType("int") + .HasColumnName("CATEGORY"); + + b.Property("CohortName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("COHORT_NAME"); + + b.Property("DateCreated") + .HasColumnType("datetime") + .HasColumnName("DATE_CREATED"); + + b.Property("DateResolved") + .HasColumnType("date") + .HasColumnName("DATE_RESOLVED"); + + b.Property("ErrorRecord") + .HasColumnType("nvarchar(max)") + .HasColumnName("ERROR_RECORD"); + + b.Property("ExceptionDate") + .HasColumnType("datetime") + .HasColumnName("EXCEPTION_DATE"); + + b.Property("FileName") + .HasMaxLength(250) + .HasColumnType("nvarchar(250)") + .HasColumnName("FILE_NAME"); + + b.Property("IsFatal") + .HasColumnType("smallint") + .HasColumnName("IS_FATAL"); + + b.Property("NhsNumber") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("NHS_NUMBER"); + + b.Property("RecordUpdatedDate") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATED_DATE"); + + b.Property("RuleDescription") + .HasColumnType("nvarchar(max)") + .HasColumnName("RULE_DESCRIPTION"); + + b.Property("RuleId") + .HasColumnType("int") + .HasColumnName("RULE_ID"); + + b.Property("ScreeningName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("SCREENING_NAME"); + + b.Property("ServiceNowCreatedDate") + .HasColumnType("date") + .HasColumnName("SERVICENOW_CREATED_DATE"); + + b.Property("ServiceNowId") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("SERVICENOW_ID"); + + b.HasKey("ExceptionId"); + + b.HasIndex(new[] { "NhsNumber", "ScreeningName" }, "IX_EXCEPTIONMGMT_NHSNUM_SCREENINGNAME"); + + b.ToTable("EXCEPTION_MANAGEMENT", "dbo"); + }); + + modelBuilder.Entity("Model.ExcludedSMULookup", b => + { + b.Property("GpPracticeCode") + .HasColumnType("nvarchar(450)") + .HasColumnName("GP_PRACTICE_CODE"); + + b.HasKey("GpPracticeCode"); + + b.ToTable("EXCLUDED_SMU_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.GenderMaster", b => + { + b.Property("GenderCd") + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("GENDER_CD"); + + b.Property("GenderDesc") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("GENDER_DESC"); + + b.HasKey("GenderCd"); + + b.ToTable("GENDER_MASTER", "dbo"); + }); + + modelBuilder.Entity("Model.GeneCodeLkp", b => + { + b.Property("GeneCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("GENE_CODE_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GeneCodeId")); + + b.Property("GeneCode") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("GENE_CODE"); + + b.Property("GeneCodeDescription") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("GENE_CODE_DESCRIPTION"); + + b.HasKey("GeneCodeId"); + + b.ToTable("GENE_CODE_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.HigherRiskReferralReasonLkp", b => + { + b.Property("HigherRiskReferralReasonId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("HIGHER_RISK_REFERRAL_REASON_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("HigherRiskReferralReasonId")); + + b.Property("HigherRiskReferralReasonCode") + .HasColumnType("nvarchar(max)") + .HasColumnName("HIGHER_RISK_REFERRAL_REASON_CODE"); + + b.Property("HigherRiskReferralReasonCodeDescription") + .HasColumnType("nvarchar(max)") + .HasColumnName("HIGHER_RISK_REFERRAL_REASON_CODE_DESCRIPTION"); + + b.HasKey("HigherRiskReferralReasonId"); + + b.ToTable("HIGHER_RISK_REFERRAL_REASON_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.LanguageCode", b => + { + b.Property("LanguageCodeId") + .HasColumnType("nvarchar(450)") + .HasColumnName("LANGUAGE_CODE"); + + b.Property("LanguageDescription") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("LANGUAGE_DESCRIPTION"); + + b.HasKey("LanguageCodeId"); + + b.ToTable("LANGUAGE_CODES", "dbo"); + }); + + modelBuilder.Entity("Model.NemsSubscription", b => + { + b.Property("SubscriptionId") + .HasColumnType("nvarchar(450)") + .HasColumnName("SUBSCRIPTION_ID"); + + b.Property("NhsNumber") + .HasColumnType("bigint") + .HasColumnName("NHS_NUMBER"); + + b.Property("RecordInsertDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_INSERT_DATETIME"); + + b.Property("RecordUpdateDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATE_DATETIME"); + + b.Property("SubscriptionSource") + .HasColumnType("int") + .HasColumnName("SUBSCRIPTION_SOURCE"); + + b.HasKey("SubscriptionId"); + + b.ToTable("NEMS_SUBSCRIPTION", "dbo"); + }); + + modelBuilder.Entity("Model.ParticipantDemographic", b => + { + b.Property("ParticipantId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("PARTICIPANT_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ParticipantId")); + + b.Property("AddressLine1") + .HasColumnType("nvarchar(max)") + .HasColumnName("ADDRESS_LINE_1"); + + b.Property("AddressLine2") + .HasColumnType("nvarchar(max)") + .HasColumnName("ADDRESS_LINE_2"); + + b.Property("AddressLine3") + .HasColumnType("nvarchar(max)") + .HasColumnName("ADDRESS_LINE_3"); + + b.Property("AddressLine4") + .HasColumnType("nvarchar(max)") + .HasColumnName("ADDRESS_LINE_4"); + + b.Property("AddressLine5") + .HasColumnType("nvarchar(max)") + .HasColumnName("ADDRESS_LINE_5"); + + b.Property("CurrentPosting") + .HasColumnType("nvarchar(max)") + .HasColumnName("CURRENT_POSTING"); + + b.Property("CurrentPostingFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("CURRENT_POSTING_FROM_DT"); + + b.Property("DateOfBirth") + .HasColumnType("nvarchar(max)") + .HasColumnName("DATE_OF_BIRTH"); + + b.Property("DateOfDeath") + .HasColumnType("nvarchar(max)") + .HasColumnName("DATE_OF_DEATH"); + + b.Property("DeathStatus") + .HasColumnType("smallint") + .HasColumnName("DEATH_STATUS"); + + b.Property("EmailAddressHome") + .HasColumnType("nvarchar(max)") + .HasColumnName("EMAIL_ADDRESS_HOME"); + + b.Property("EmailAddressHomeFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("EMAIL_ADDRESS_HOME_FROM_DT"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)") + .HasColumnName("FAMILY_NAME"); + + b.Property("Gender") + .HasColumnType("smallint") + .HasColumnName("GENDER"); + + b.Property("GivenName") + .HasColumnType("nvarchar(max)") + .HasColumnName("GIVEN_NAME"); + + b.Property("InterpreterRequired") + .HasColumnType("smallint") + .HasColumnName("INTERPRETER_REQUIRED"); + + b.Property("InvalidFlag") + .HasColumnType("smallint") + .HasColumnName("INVALID_FLAG"); + + b.Property("NamePrefix") + .HasColumnType("nvarchar(max)") + .HasColumnName("NAME_PREFIX"); + + b.Property("NhsNumber") + .HasColumnType("bigint") + .HasColumnName("NHS_NUMBER"); + + b.Property("OtherGivenName") + .HasColumnType("nvarchar(max)") + .HasColumnName("OTHER_GIVEN_NAME"); + + b.Property("PafKey") + .HasColumnType("nvarchar(max)") + .HasColumnName("PAF_KEY"); + + b.Property("PostCode") + .HasColumnType("nvarchar(max)") + .HasColumnName("POST_CODE"); + + b.Property("PreferredLanguage") + .HasColumnType("nvarchar(max)") + .HasColumnName("PREFERRED_LANGUAGE"); + + b.Property("PreviousFamilyName") + .HasColumnType("nvarchar(max)") + .HasColumnName("PREVIOUS_FAMILY_NAME"); + + b.Property("PrimaryCareProvider") + .HasColumnType("nvarchar(max)") + .HasColumnName("PRIMARY_CARE_PROVIDER"); + + b.Property("PrimaryCareProviderFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("PRIMARY_CARE_PROVIDER_FROM_DT"); + + b.Property("RecordInsertDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_INSERT_DATETIME"); + + b.Property("RecordUpdateDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATE_DATETIME"); + + b.Property("SupersededByNhsNumber") + .HasColumnType("bigint") + .HasColumnName("SUPERSEDED_BY_NHS_NUMBER"); + + b.Property("TelephoneNumberHome") + .HasColumnType("nvarchar(max)") + .HasColumnName("TELEPHONE_NUMBER_HOME"); + + b.Property("TelephoneNumberHomeFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("TELEPHONE_NUMBER_HOME_FROM_DT"); + + b.Property("TelephoneNumberMob") + .HasColumnType("nvarchar(max)") + .HasColumnName("TELEPHONE_NUMBER_MOB"); + + b.Property("TelephoneNumberMobFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("TELEPHONE_NUMBER_MOB_FROM_DT"); + + b.Property("UsualAddressFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("USUAL_ADDRESS_FROM_DT"); + + b.HasKey("ParticipantId"); + + b.HasIndex(new[] { "NhsNumber" }, "Index_PARTICIPANT_DEMOGRAPHIC_NhsNumber"); + + b.ToTable("PARTICIPANT_DEMOGRAPHIC", "dbo"); + }); + + modelBuilder.Entity("Model.ParticipantManagement", b => + { + b.Property("ParticipantId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("PARTICIPANT_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ParticipantId")); + + b.Property("BlockedFlag") + .HasColumnType("smallint") + .HasColumnName("BLOCKED_FLAG"); + + b.Property("BusinessRuleVersion") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("BUSINESS_RULE_VERSION"); + + b.Property("DateIrradiated") + .HasColumnType("datetime") + .HasColumnName("DATE_IRRADIATED"); + + b.Property("EligibilityFlag") + .HasColumnType("smallint") + .HasColumnName("ELIGIBILITY_FLAG"); + + b.Property("ExceptionFlag") + .HasColumnType("smallint") + .HasColumnName("EXCEPTION_FLAG"); + + b.Property("GeneCodeId") + .HasColumnType("int") + .HasColumnName("GENE_CODE_ID"); + + b.Property("HigherRiskNextTestDueDate") + .HasColumnType("datetime") + .HasColumnName("HIGHER_RISK_NEXT_TEST_DUE_DATE"); + + b.Property("HigherRiskReferralReasonId") + .HasColumnType("int") + .HasColumnName("HIGHER_RISK_REFERRAL_REASON_ID"); + + b.Property("IsHigherRisk") + .HasColumnType("smallint") + .HasColumnName("IS_HIGHER_RISK"); + + b.Property("IsHigherRiskActive") + .HasColumnType("smallint") + .HasColumnName("IS_HIGHER_RISK_ACTIVE"); + + b.Property("NHSNumber") + .HasColumnType("bigint") + .HasColumnName("NHS_NUMBER"); + + b.Property("NextTestDueDate") + .HasColumnType("datetime") + .HasColumnName("NEXT_TEST_DUE_DATE"); + + b.Property("NextTestDueDateCalcMethod") + .HasColumnType("nvarchar(max)") + .HasColumnName("NEXT_TEST_DUE_DATE_CALC_METHOD"); + + b.Property("ParticipantScreeningStatus") + .HasColumnType("nvarchar(max)") + .HasColumnName("PARTICIPANT_SCREENING_STATUS"); + + b.Property("ReasonForRemoval") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("REASON_FOR_REMOVAL"); + + b.Property("ReasonForRemovalDate") + .HasColumnType("datetime") + .HasColumnName("REASON_FOR_REMOVAL_FROM_DT"); + + b.Property("RecordInsertDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_INSERT_DATETIME"); + + b.Property("RecordType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("RECORD_TYPE"); + + b.Property("RecordUpdateDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATE_DATETIME"); + + b.Property("ReferralFlag") + .HasColumnType("smallint") + .HasColumnName("REFERRAL_FLAG"); + + b.Property("ScreeningCeasedReason") + .HasColumnType("nvarchar(max)") + .HasColumnName("SCREENING_CEASED_REASON"); + + b.Property("ScreeningId") + .HasColumnType("bigint") + .HasColumnName("SCREENING_ID"); + + b.Property("SrcSysProcessedDateTime") + .HasColumnType("datetime") + .HasColumnName("SRC_SYSTEM_PROCESSED_DATETIME"); + + b.HasKey("ParticipantId"); + + b.HasIndex(new[] { "NHSNumber", "ScreeningId" }, "ix_PARTICIPANT_MANAGEMENT_screening_nhs"); + + b.ToTable("PARTICIPANT_MANAGEMENT", "dbo"); + }); + + modelBuilder.Entity("Model.ScreeningLkp", b => + { + b.Property("ScreeningWorkflowId") + .HasColumnType("nvarchar(450)") + .HasColumnName("SCREENING_WORKFLOW_ID"); + + b.Property("ScreeningAcronym") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("SCREENING_ACRONYM"); + + b.Property("ScreeningId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("SCREENING_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ScreeningId")); + + b.Property("ScreeningName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("SCREENING_NAME"); + + b.Property("ScreeningType") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("SCREENING_TYPE"); + + b.HasKey("ScreeningWorkflowId"); + + b.ToTable("SCREENING_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.ServicenowCases", b => + { + b.Property("ServicenowId") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("SERVICENOW_ID"); + + b.Property("NhsNumber") + .HasColumnType("bigint") + .HasColumnName("NHS_NUMBER"); + + b.Property("RecordInsertDatetime") + .HasColumnType("datetime") + .HasColumnName("RECORD_INSERT_DATETIME"); + + b.Property("RecordUpdateDatetime") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATE_DATETIME"); + + b.Property("Status") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("STATUS"); + + b.HasKey("ServicenowId"); + + b.ToTable("SERVICENOW_CASES", "dbo"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.cs b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.cs new file mode 100644 index 0000000000..b183a6b19f --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataServices.Migrations.Migrations +{ + /// + public partial class add_subscription_source_to_nems : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SUBSCRIPTION_SOURCE", + schema: "dbo", + table: "NEMS_SUBSCRIPTION", + type: "int", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SUBSCRIPTION_SOURCE", + schema: "dbo", + table: "NEMS_SUBSCRIPTION"); + } + } +} diff --git a/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/DataServicesContextModelSnapshot.cs b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/DataServicesContextModelSnapshot.cs index 62324b3eb3..61f7842767 100644 --- a/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/DataServicesContextModelSnapshot.cs +++ b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/DataServicesContextModelSnapshot.cs @@ -721,6 +721,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("datetime") .HasColumnName("RECORD_UPDATE_DATETIME"); + b.Property("SubscriptionSource") + .HasColumnType("int") + .HasColumnName("SUBSCRIPTION_SOURCE"); + b.HasKey("SubscriptionId"); b.ToTable("NEMS_SUBSCRIPTION", "dbo"); diff --git a/application/CohortManager/src/Functions/Shared/Model/EFModels/NemsSubscription.cs b/application/CohortManager/src/Functions/Shared/Model/EFModels/NemsSubscription.cs index 1190b27ff1..fbb942323e 100644 --- a/application/CohortManager/src/Functions/Shared/Model/EFModels/NemsSubscription.cs +++ b/application/CohortManager/src/Functions/Shared/Model/EFModels/NemsSubscription.cs @@ -18,4 +18,7 @@ public class NemsSubscription [Column("RECORD_UPDATE_DATETIME", TypeName = "datetime")] public DateTime? RecordUpdateDateTime { get; set; } + + [Column("SUBSCRIPTION_SOURCE", TypeName = "int")] + public SubscriptionSource? SubscriptionSource { get; set; } } diff --git a/application/CohortManager/src/Functions/Shared/Model/Enums/SubscriptionSource.cs b/application/CohortManager/src/Functions/Shared/Model/Enums/SubscriptionSource.cs new file mode 100644 index 0000000000..3e7efa9d2f --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Model/Enums/SubscriptionSource.cs @@ -0,0 +1,8 @@ +namespace Model; + +public enum SubscriptionSource +{ + NEMS = 1, + MESH = 2 +} + diff --git a/infrastructure/tf-core/environments/development.tfvars b/infrastructure/tf-core/environments/development.tfvars index e35de32601..04d80000c8 100644 --- a/infrastructure/tf-core/environments/development.tfvars +++ b/infrastructure/tf-core/environments/development.tfvars @@ -1057,6 +1057,23 @@ function_apps = { } } + ManageCaasSubscription = { + name_suffix = "manage-caas-subscription" + function_endpoint_name = "ManageCaasSubscription" + app_service_plan_key = "NonScaling" + db_connection_string = "DtOsDatabaseConnectionString" + key_vault_url = "KeyVaultConnectionString" + env_vars_static = { + IsStubbed = "true" + } + app_urls = [ + { + env_var_name = "ExceptionFunctionURL" + function_app_key = "CreateException" + } + ] + } + ReferenceDataService = { name_suffix = "reference-data-service" function_endpoint_name = "ReferenceDataService" diff --git a/tests/UnitTests/ConsolidatedTests.csproj b/tests/UnitTests/ConsolidatedTests.csproj index a3fc160f3b..a8d104a096 100644 --- a/tests/UnitTests/ConsolidatedTests.csproj +++ b/tests/UnitTests/ConsolidatedTests.csproj @@ -93,6 +93,7 @@ + diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs new file mode 100644 index 0000000000..b682514394 --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -0,0 +1,363 @@ +namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; + +using System.Net; +using System.Collections.Specialized; +using System.Threading.Tasks; +using Common; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NHS.CohortManager.DemographicServices; +using NHS.CohortManager.Tests.TestUtils; +using System.Net.Http; +using DataServices.Core; +using Model; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Common.Interfaces; + +[TestClass] +public class ManageCaasSubscriptionTests +{ + private readonly CreateResponse _createResponse = new(); + private readonly Mock> _logger = new(); + private readonly SetupRequest _setupRequest = new(); + private readonly ManageCaasSubscription _sut; + private readonly Mock> _config = new(); + private readonly Mock _mesh = new(); + private readonly Mock> _requestHandler = new(); + private readonly Mock> _nemsAccessor = new(); + private readonly Mock _meshPoller = new(); + private readonly Mock _exceptionHandler = new(); + + public ManageCaasSubscriptionTests() + { + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig + { + CaasToMailbox = "TEST_TO", + CaasFromMailbox = "TEST_FROM", + MeshApiBaseUrl = "http://localhost", + MeshCaasSharedKey = "dummy" + }); + + _mesh + .Setup(m => m.SendSubscriptionRequest(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("STUB_MSG_ID"); + + _nemsAccessor + .Setup(a => a.GetSingle(It.IsAny>>() )) + .ReturnsAsync(new NemsSubscription { NhsNumber = 9000000009, SubscriptionId = "SUB123" }); + + _requestHandler + .Setup(r => r.HandleRequest(It.IsAny(), It.IsAny())) + .ReturnsAsync((HttpRequestData r, string k) => _createResponse.CreateHttpResponse(HttpStatusCode.OK, r, "OK")); + + _nemsAccessor + .Setup(a => a.InsertSingle(It.IsAny())) + .ReturnsAsync(true); + + _exceptionHandler + .Setup(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + _sut = new ManageCaasSubscription( + _logger.Object, + _createResponse, + _config.Object, + _mesh.Object, + _requestHandler.Object, + _nemsAccessor.Object, + _meshPoller.Object, + _exceptionHandler.Object + ); + } + + [TestMethod] + public async Task Subscribe_Valid_ReturnsOk() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + } + + [TestMethod] + public async Task Subscribe_Invalid_ReturnsBadRequest() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "abc" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.BadRequest, res.StatusCode); + } + + [TestMethod] + public async Task Unsubscribe_Valid_ReturnsOk() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Unsubscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + } + + [TestMethod] + public async Task Unsubscribe_Invalid_ReturnsBadRequest() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "" } }, HttpMethod.Post); + var res = await _sut.Unsubscribe(req.Object); + Assert.AreEqual(HttpStatusCode.BadRequest, res.StatusCode); + } + + [TestMethod] + public async Task CheckSubscriptionStatus_Valid_ReturnsOk() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Get); + var res = await _sut.CheckSubscriptionStatus(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + } + + [DataTestMethod] + [DataRow((string)null)] + [DataRow("")] + [DataRow("abc")] + [DataRow("12345")] + public async Task CheckSubscriptionStatus_InvalidInputs_ReturnsBadRequest(string nhsNumber) + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", nhsNumber } }, HttpMethod.Get); + var res = await _sut.CheckSubscriptionStatus(req.Object); + Assert.AreEqual(HttpStatusCode.BadRequest, res.StatusCode); + } + + [TestMethod] + public async Task DataService_ReturnsOk() + { + var req = _setupRequest.Setup(null, new NameValueCollection(), HttpMethod.Get); + var res = await _sut.NemsSubscriptionDataService(req.Object, "key"); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + } + + [TestMethod] + public async Task CheckSubscriptionStatus_Found_ReturnsOk() + { + _nemsAccessor + .Setup(a => a.GetSingle(It.IsAny>>() )) + .ReturnsAsync(new NemsSubscription { NhsNumber = 9000000009, SubscriptionId = "SUB123" }); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Get); + + // Act + var res = await _sut.CheckSubscriptionStatus(req.Object); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + } + + [TestMethod] + public async Task NemsSubscriptionDataService_ValidRequest_DelegatesToHandler() + { + var req = _setupRequest.Setup(null, new NameValueCollection(), HttpMethod.Get); + var expected = _createResponse.CreateHttpResponse(HttpStatusCode.OK, req.Object, "OK"); + _requestHandler + .Setup(r => r.HandleRequest(It.IsAny(), It.IsAny())) + .ReturnsAsync(expected); + + // Act + var res = await _sut.NemsSubscriptionDataService(req.Object, "my-key"); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + _requestHandler.Verify(r => r.HandleRequest(It.Is(h => object.ReferenceEquals(h, req.Object)), "my-key"), Times.Once); + } + + [TestMethod] + public async Task NemsSubscriptionDataService_HandlerThrows_ReturnsInternalServerError() + { + var req = _setupRequest.Setup(null, new NameValueCollection(), HttpMethod.Get); + _requestHandler + .Setup(r => r.HandleRequest(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("boom")); + var res = await _sut.NemsSubscriptionDataService(req.Object, "key"); + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "", nameof(ManageCaasSubscription), "", It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task CheckSubscriptionStatus_NotFound_ReturnsNotFound() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Get); + _nemsAccessor + .Setup(a => a.GetSingle(It.IsAny>>() )) + .ReturnsAsync((NemsSubscription?)null); + + var res = await _sut.CheckSubscriptionStatus(req.Object); + Assert.AreEqual(HttpStatusCode.NotFound, res.StatusCode); + } + + [TestMethod] + public async Task CheckSubscriptionStatus_AccessorThrows_ReturnsInternalServerError() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Get); + _nemsAccessor + .Setup(a => a.GetSingle(It.IsAny>>() )) + .ThrowsAsync(new Exception("db-error")); + var res = await _sut.CheckSubscriptionStatus(req.Object); + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "", It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Subscribe_MeshCalled_WithConfigMailboxes() + { + _nemsAccessor.Setup(a => a.InsertSingle(It.IsAny())).ReturnsAsync(true); + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + _mesh.Verify(m => m.SendSubscriptionRequest(9000000009L, "TEST_TO", "TEST_FROM"), Times.Once); + _nemsAccessor.Verify(a => a.InsertSingle(It.Is(n => n.NhsNumber == 9000000009L && n.SubscriptionSource == SubscriptionSource.MESH && !string.IsNullOrEmpty(n.SubscriptionId))), Times.Once); + } + + [TestMethod] + public async Task Subscribe_MeshThrows_ReturnsInternalServerError() + { + _mesh + .Setup(m => m.SendSubscriptionRequest(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("mesh-down")); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "", It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Subscribe_DBInsertFails_ReturnsInternalServerError() + { + _nemsAccessor.Setup(a => a.InsertSingle(It.IsAny())).ReturnsAsync(false); + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "", It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Subscribe_LogsStubMessage_WhenIsStubbedTrue() + { + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig + { + CaasToMailbox = "TEST_TO", + CaasFromMailbox = "TEST_FROM", + MeshApiBaseUrl = "http://localhost", + MeshCaasSharedKey = "dummy", + IsStubbed = true + }); + + var sut = new ManageCaasSubscription( + _logger.Object, + _createResponse, + _config.Object, + _mesh.Object, + _requestHandler.Object, + _nemsAccessor.Object, + _meshPoller.Object, + _exceptionHandler.Object + ); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + _logger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("MESH stub")), + It.IsAny(), + It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Subscribe_LogsRealMessage_WhenIsStubbedFalse() + { + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig + { + CaasToMailbox = "TEST_TO", + CaasFromMailbox = "TEST_FROM", + MeshApiBaseUrl = "http://localhost", + MeshCaasSharedKey = "dummy", + IsStubbed = false + }); + + var sut = new ManageCaasSubscription( + _logger.Object, + _createResponse, + _config.Object, + _mesh.Object, + _requestHandler.Object, + _nemsAccessor.Object, + _meshPoller.Object, + _exceptionHandler.Object + ); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + _logger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("sent to MESH")), + It.IsAny(), + It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Unsubscribe_LogsStubMessage() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Unsubscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + _logger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("[CAAS-Stub] Unsubscribe called")), + It.IsAny(), + It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Subscribe_MeshReturnsNull_ReturnsInternalServerError() + { + _mesh + .Setup(m => m.SendSubscriptionRequest(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string?)null); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "", It.IsAny()), Times.Once); + _nemsAccessor.Verify(a => a.InsertSingle(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PollMeshMailbox_UsesConfigFromMailbox() + { + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig + { + CaasFromMailbox = "TEST_FROM", + CaasToMailbox = "TEST_TO", + MeshApiBaseUrl = "http://localhost", + MeshCaasSharedKey = "dummy" + }); + + var sut = new ManageCaasSubscription( + _logger.Object, + _createResponse, + _config.Object, + _mesh.Object, + _requestHandler.Object, + _nemsAccessor.Object, + _meshPoller.Object, + _exceptionHandler.Object + ); + + await sut.RunAsync(null); + _meshPoller.Verify(p => p.ExecuteHandshake("TEST_FROM"), Times.Once); + } + + +} diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj new file mode 100644 index 0000000000..7b53f23ae7 --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj @@ -0,0 +1,26 @@ + + + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E} + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs new file mode 100644 index 0000000000..eaa486c97a --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs @@ -0,0 +1,58 @@ +namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; + +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography; +using System.IO; +using System; +using Common; +using Microsoft.Extensions.Logging.Abstractions; + +[TestClass] +public class MeshMailboxExtensionTests +{ + [TestMethod] + public async Task GetCACertificates_FromFilePath_ReturnsCollection() + { + // Arrange: create a temporary self-signed certificate and write as PEM to a temp file + using var rsa = RSA.Create(2048); + var subject = new X500DistinguishedName("CN=Test"); + var req = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var der = cert.Export(X509ContentType.Cert); + var pem = "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + + var tempPath = Path.Combine(Path.GetTempPath(), $"test-cert-{Guid.NewGuid():N}.pem"); + await File.WriteAllTextAsync(tempPath, pem); + + try + { + // Act + var certs = await MeshMailboxExtension.GetCACertificates(NullLogger.Instance, tempPath, null); + + // Assert + Assert.IsNotNull(certs); + Assert.IsInstanceOfType(certs, typeof(X509Certificate2Collection)); + Assert.IsTrue(certs!.Count > 0); + } + finally + { + if (File.Exists(tempPath)) File.Delete(tempPath); + } + } + + [TestMethod] + public async Task GetCACertificates_NoInputs_ReturnsNull() + { + // Arrange: no inputs + + // Act + var certs = await MeshMailboxExtension.GetCACertificates(NullLogger.Instance, null, null); + + // Assert + Assert.IsNull(certs); + } + // kept for potential future use; not used after temp-cert approach + private static string FindInParents(string fileName) => fileName; +} diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs new file mode 100644 index 0000000000..4a4106b71f --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs @@ -0,0 +1,59 @@ +namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; + +using System.Threading.Tasks; +using Common; +using Microsoft.Extensions.Logging; +using Moq; +using NHS.MESH.Client.Contracts.Services; +using NHS.MESH.Client.Models; + +[TestClass] +public class MeshPollerTests +{ + private readonly Mock> _logger = new(); + private readonly Mock _meshOps = new(); + + [TestMethod] + public async Task ExecuteHandshake_Success_ReturnsTrue() + { + // Arrange + _meshOps + .Setup(m => m.MeshHandshakeAsync(It.IsAny())) + .ReturnsAsync(new MeshResponse { IsSuccessful = true, Response = new HandshakeResponse { MailboxId = "MAILBOX" } }); + + var sut = new MeshPoller(_logger.Object, _meshOps.Object); + + // Act + var result = await sut.ExecuteHandshake("MAILBOX"); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public async Task ExecuteHandshake_Failure_ReturnsFalse() + { + // Arrange + _meshOps + .Setup(m => m.MeshHandshakeAsync(It.IsAny())) + .ReturnsAsync(new MeshResponse { IsSuccessful = false, Error = new APIErrorResponse { ErrorCode = "500", ErrorDescription = "err" } }); + + var sut = new MeshPoller(_logger.Object, _meshOps.Object); + + // Act + var result = await sut.ExecuteHandshake("MAILBOX"); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public async Task ShouldExecuteHandshake_NotImplemented_Throws() + { + // Arrange + var sut = new MeshPoller(_logger.Object, _meshOps.Object); + + // Act & Assert + await Assert.ThrowsExceptionAsync(() => sut.ShouldExecuteHandshake("MAILBOX", "config.json")); + } +} diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs new file mode 100644 index 0000000000..53912cd47f --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs @@ -0,0 +1,79 @@ +namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; + +using System.Threading.Tasks; +using Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NHS.MESH.Client.Contracts.Services; +using NHS.MESH.Client.Models; + +[TestClass] +public class MeshSendCaasSubscribeTests +{ + private readonly Mock> _logger = new(); + private readonly Mock _meshOutbox = new(); + + private MeshSendCaasSubscribe CreateSut(string workflowId = "WF-CAAS-SUB") + { + var cfg = Options.Create(new MeshSendCaasSubscribeConfig { SendCaasWorkflowId = workflowId }); + return new MeshSendCaasSubscribe(_logger.Object, _meshOutbox.Object, cfg); + } + + [TestMethod] + public async Task SendSubscriptionRequest_Success_SendsExpectedAttachment_AndReturnsMessageId() + { + // Arrange + string? capturedFrom = null, capturedTo = null, capturedWorkflow = null; + FileAttachment? capturedFile = null; + _meshOutbox + .Setup(m => m.SendCompressedMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null, null, false)) + .Callback((string from, string to, string workflow, FileAttachment file, string? _, string? __, bool ___) => + { + capturedFrom = from; capturedTo = to; capturedWorkflow = workflow; capturedFile = file; + }) + .ReturnsAsync(new MeshResponse + { + IsSuccessful = true, + Response = new SendMessageResponse { MessageId = "MSG123" } + }); + + var sut = CreateSut(); + + // Act + var result = await sut.SendSubscriptionRequest(9000000009L, "TO_BOX", "FROM_BOX"); + + // Assert + Assert.AreEqual("MSG123", result); + Assert.AreEqual("FROM_BOX", capturedFrom); + Assert.AreEqual("TO_BOX", capturedTo); + Assert.AreEqual("WF-CAAS-SUB", capturedWorkflow); + Assert.IsNotNull(capturedFile); + Assert.AreEqual("CaaSSubscribe.parquet", capturedFile!.FileName); + Assert.AreEqual("application/octet-stream", capturedFile.ContentType); + Assert.IsNotNull(capturedFile.Content); + Assert.IsTrue(capturedFile.Content.Length > 0); + } + + [TestMethod] + public async Task SendSubscriptionRequest_Failure_ReturnsNull() + { + // Arrange + _meshOutbox + .Setup(m => m.SendCompressedMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null, null, false)) + .ReturnsAsync(new MeshResponse + { + IsSuccessful = false, + Error = new APIErrorResponse { ErrorCode = "500", ErrorDescription = "boom" } + }); + + var sut = CreateSut(); + + // Act + var result = await sut.SendSubscriptionRequest(9000000009L, "TO_BOX", "FROM_BOX"); + + // Assert + Assert.IsNull(result); + } +} + diff --git a/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/MeshCaaSSubscribeIntegrationTests.csproj b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/MeshCaaSSubscribeIntegrationTests.csproj new file mode 100644 index 0000000000..aff0d515f7 --- /dev/null +++ b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/MeshCaaSSubscribeIntegrationTests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + latest + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/ParquetAsserts.cs b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/ParquetAsserts.cs new file mode 100644 index 0000000000..9b634e0c1f --- /dev/null +++ b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/ParquetAsserts.cs @@ -0,0 +1,29 @@ +namespace MeshCaaSSubscribeIntegrationTests; + +using System.IO; +using NHS.MESH.Client.Models; +using ParquetSharp; +using ParquetSharp.IO; +public static class ParquetAsserts +{ + public static void ContainsExpectedNhsNumber(byte[] parquetBytes, long expectedNhsNumber) + { + using var stream = new MemoryStream(parquetBytes); + using var reader = new ManagedRandomAccessFile(stream); + using var file = new ParquetFileReader(reader); + + var rowGroupCount = file.FileMetaData.NumRowGroups; + Assert.AreEqual(1, rowGroupCount, "Expected exactly 1 row group."); + + using var rowGroup = file.RowGroup(0); + + var columnCount = rowGroup.MetaData.NumColumns; + Assert.AreEqual(1, columnCount, "Expected exactly 1 column."); + + using var columnReader = rowGroup.Column(0).LogicalReader(); + var values = columnReader.ReadAll(checked((int)rowGroup.MetaData.NumRows)); + + Assert.AreEqual(1, values.Length, "Expected exactly 1 row."); + Assert.AreEqual(expectedNhsNumber, values[0], "NHS number does not match."); + } +} diff --git a/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs new file mode 100644 index 0000000000..ae7fe5735c --- /dev/null +++ b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs @@ -0,0 +1,93 @@ +namespace MeshCaaSSubscribeIntegrationTests; + +using Microsoft.Extensions.DependencyInjection; +using NHS.MESH.Client.Contracts.Services; +using NHS.MESH.Client; +using NHS.MESH.Client.Models; +using NHS.MESH.Client.Contracts.Configurations; +using NHS.MESH.Client.Helpers; +using Common; +using Moq; +using Castle.Core.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Threading.Tasks; +using NHS.MESH.Client.Helpers.ContentHelpers; + +[TestCategory("Integration")] +[TestClass] +public sealed class SendCaasSubscribeTests +{ + private readonly IMeshOutboxService _meshOutboxService; + private readonly IMeshInboxService _meshInboxService; + private readonly IMeshConnectConfiguration _config; + private readonly Mock> _mockLogger = new(); + private readonly Mock> _options = new(); + + private readonly MeshSendCaasSubscribe _sut; + + private const string toMailbox = "X26ABC2"; + private const string fromMailbox = "X26ABC1"; + private const string workflowId = "TEST-WORKFLOW"; + public SendCaasSubscribeTests() + { + var services = new ServiceCollection(); + + services.AddMeshClient(options => + { + options.MeshApiBaseUrl = "http://localhost:8700/messageexchange"; + }) + .AddMailbox(fromMailbox, + new NHS.MESH.Client.Configuration.MailboxConfiguration + { + Password = "password", + SharedKey = "TestKey" + }) + .AddMailbox(toMailbox, + new NHS.MESH.Client.Configuration.MailboxConfiguration + { + Password = "password", + SharedKey = "TestKey" + }) + .Build(); + + _options.Setup(i => i.Value).Returns(new MeshSendCaasSubscribeConfig + { + SendCaasWorkflowId = "Workflow" + }); + + var serviceProvider = services.BuildServiceProvider(); + _meshInboxService = serviceProvider.GetService()!; + _meshOutboxService = serviceProvider.GetService()!; + _config = serviceProvider.GetService()!; + + _sut = new(_mockLogger.Object, _meshOutboxService, _options.Object); + } + + [TestMethod] + public async Task SendSubscriptionRequest_SendsNormalNhsNumber_ReturnsMessageId() + { + // arrange + long nhsNumber = 9995534991; + + // act + var messageId = await _sut.SendSubscriptionRequest(nhsNumber, toMailbox, fromMailbox); + + // assert + Assert.IsNotNull(messageId); + + // act - validate message recieved + var getMessagesResult = await _meshInboxService.GetMessagesAsync(toMailbox); + + // assert - File is in mesh + Assert.IsTrue(getMessagesResult.Response.Messages.Contains(messageId)); + + // act - download message and decompress message + var message = await _meshInboxService.GetMessageByIdAsync(toMailbox, messageId); + var fileContent = GZIPHelpers.DeCompressBuffer(message.Response.FileAttachment.Content); + + // asset - ensure message contains expected parquet file + ParquetAsserts.ContainsExpectedNhsNumber(fileContent, nhsNumber); + + } +}