From a8d92ff2d7aadac035cac386ad85678afec8cd40 Mon Sep 17 00:00:00 2001 From: jolyonbrownnhs Date: Tue, 17 Mar 2026 13:18:25 +0000 Subject: [PATCH] HCW-345 add sandbox-only lambda handler --- infrastructure/modules/hcw-api/lambda.tf | 3 +- scripts/build-app.sh | 2 + src/ldap/connection_fetch.py | 25 +-- src/ldap/connection_fetch_test.py | 17 +-- src/ldap/mock_connection.py | 22 --- src/request_handlers/handlers.py | 1 - .../sandbox_responses_test.py | 142 ++++++++++++++++++ .../sandbox_static_responses.py | 77 ++++++++++ src/sandbox_main.py | 56 +++++++ 9 files changed, 290 insertions(+), 55 deletions(-) delete mode 100644 src/ldap/mock_connection.py create mode 100644 src/request_handlers/sandbox_responses_test.py create mode 100644 src/request_handlers/sandbox_static_responses.py create mode 100644 src/sandbox_main.py diff --git a/infrastructure/modules/hcw-api/lambda.tf b/infrastructure/modules/hcw-api/lambda.tf index c29dcd27..8676fd72 100644 --- a/infrastructure/modules/hcw-api/lambda.tf +++ b/infrastructure/modules/hcw-api/lambda.tf @@ -19,7 +19,7 @@ resource "aws_lambda_function" "hcw-app" { s3_bucket = data.aws_s3_bucket.app_deployment.id s3_key = var.s3_filename - handler = "main.lambda_handler" + handler = var.sandbox_mode ? "sandbox_main.lambda_handler" : "main.lambda_handler" timeout = 30 memory_size = 512 @@ -41,7 +41,6 @@ resource "aws_lambda_function" "hcw-app" { variables = { LDAP_CREDENTIALS_SECRET_ID = data.aws_secretsmanager_secret.ldap_credentials.arn LDAP_GATEWAY_URL = var.ldap_gateway_url - SANDBOX_MODE = var.sandbox_mode BASE_URL = "https://${var.apim_environment}.api.service.nhs.uk/healthcare-worker" # Extension configuration (optional - using defaults) diff --git a/scripts/build-app.sh b/scripts/build-app.sh index cf6e706f..1368d3b6 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -10,4 +10,6 @@ cd dist || exit tar -xvf hcw_api-*.tar.gz cd hcw_api-*/src || exit +mkdir -p specification/components/examples +cp ../../../specification/components/examples/*.json specification/components/examples/ zip -r ../../../hcw-api.zip . -x "*.pyc" -x "*__pycache__*" diff --git a/src/ldap/connection_fetch.py b/src/ldap/connection_fetch.py index 75366d6e..9a06197d 100644 --- a/src/ldap/connection_fetch.py +++ b/src/ldap/connection_fetch.py @@ -1,9 +1,7 @@ -import os from datetime import datetime, timedelta from typing import Optional from ldap.connection import HcwLdapConnection -from ldap.mock_connection import MockHcwLdapConnection from ldap.real_connection import RealHcwLdapConnection from logs.log import Log @@ -14,28 +12,13 @@ ldap_connection: Optional[HcwLdapConnection] = None -def is_sandbox_mode() -> bool: - """ - Check if we're running in sandbox mode. - """ - return os.environ.get("SANDBOX_MODE", "false").lower() == "true" - - def get_connection(): global ldap_connection - if is_sandbox_mode(): - # In sandbox mode, just create mock connection if we don't have one - # No need for sophisticated connection pooling with mocks - if not ldap_connection: - logger.info("Creating new mock connection for sandbox", "LDAP_CONN_MOCK", "null") - ldap_connection = MockHcwLdapConnection() - else: - # In real mode, do full connection pooling logic - if (not ldap_connection or ldap_connection.connection.closed - or ldap_connection.bind_time < datetime.now() - timedelta(minutes=5)): - logger.info("Creating new real LDAP connection", "LDAP_CONN_REAL", "null") - ldap_connection = RealHcwLdapConnection() + if (not ldap_connection or ldap_connection.connection.closed + or ldap_connection.bind_time < datetime.now() - timedelta(minutes=5)): + logger.info("Creating new real LDAP connection", "LDAP_CONN_REAL", "null") + ldap_connection = RealHcwLdapConnection() return ldap_connection diff --git a/src/ldap/connection_fetch_test.py b/src/ldap/connection_fetch_test.py index a19d0f24..b8e9badc 100644 --- a/src/ldap/connection_fetch_test.py +++ b/src/ldap/connection_fetch_test.py @@ -5,7 +5,6 @@ import ldap.connection_fetch from ldap.connection_fetch import get_connection -from ldap.mock_connection import MockHcwLdapConnection @pytest.fixture(autouse=True) @@ -15,23 +14,23 @@ def tidy_up(): ldap.connection_fetch.ldap_connection = None -@patch.dict(os.environ, {"SANDBOX_MODE": "true"}) -def test_sandbox_connection(): +@patch("ldap.connection_fetch.RealHcwLdapConnection") +def test_real_connection(connection_mock): connection = get_connection() - assert isinstance(connection, MockHcwLdapConnection) + assert connection == connection_mock.return_value -@patch.dict(os.environ, {"SANDBOX_MODE": "false"}) @patch("ldap.connection_fetch.RealHcwLdapConnection") -def test_real_connection(connection_mock): - connection = get_connection() +def test_real_connection_even_if_sandbox_mode_is_true(connection_mock): + with patch.dict(os.environ, {"SANDBOX_MODE": "true"}): + connection = get_connection() assert connection == connection_mock.return_value -@patch.dict(os.environ, {"SANDBOX_MODE": "non_boolean_value"}) @patch("ldap.connection_fetch.RealHcwLdapConnection") def test_real_connection_with_unknown_value(connection_mock): - connection = get_connection() + with patch.dict(os.environ, {"SANDBOX_MODE": "non_boolean_value"}): + connection = get_connection() assert connection == connection_mock.return_value diff --git a/src/ldap/mock_connection.py b/src/ldap/mock_connection.py deleted file mode 100644 index 90ffe6fa..00000000 --- a/src/ldap/mock_connection.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -The MockHcwLdapConnection class contains hardcoded data to return when in sandbox mode. This is instead of the -real ldaps connection so we have no chance of returning PID from the LDAPS instance. -""" - -from ldap.connection import HcwLdapConnection -from ldap.nhs_person import NhsPerson, NhsOrgPerson, NhsOrgPersonRole - - -class MockHcwLdapConnection(HcwLdapConnection): - def search_active_nhs_person(self, uid: str) -> [NhsPerson, list[NhsOrgPerson], list[NhsOrgPersonRole]]: - return NhsPerson({ - "uid": "123", - "sn": "Westbrook", - "givenName": "Tabby", - "nhsMiddleNames": "Ashlyn", - "personalTitle": "Mrs", - "nhsPersonStatus": "1", - }), [], [] - - def search_org_roles(self, uid: str) -> list[NhsOrgPersonRole]: - return [] diff --git a/src/request_handlers/handlers.py b/src/request_handlers/handlers.py index d705992a..53328459 100644 --- a/src/request_handlers/handlers.py +++ b/src/request_handlers/handlers.py @@ -1,4 +1,3 @@ -import os from typing import Dict, Type from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent diff --git a/src/request_handlers/sandbox_responses_test.py b/src/request_handlers/sandbox_responses_test.py new file mode 100644 index 00000000..6f89cdb2 --- /dev/null +++ b/src/request_handlers/sandbox_responses_test.py @@ -0,0 +1,142 @@ +import json +from pathlib import Path + +from sandbox_main import lambda_handler + +EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "specification" / "components" / "examples" +BASE_URL = "https://int.api.service.nhs.uk/healthcare-worker" +SANDBOX_UUID = "123456789012" + + +class LambdaContext: + pass + + +def load_example(file_name: str) -> dict: + with open(EXAMPLES_DIR / file_name, encoding="utf-8") as example_file: + return json.load(example_file) + + +def sort_bundle_entries(bundle: dict) -> dict: + bundle_copy = dict(bundle) + bundle_copy["entry"] = sorted( + bundle_copy.get("entry", []), + key=lambda entry: ( + entry["search"]["mode"], + entry["resource"]["resourceType"], + entry["resource"].get("id", ""), + entry["fullUrl"], + ), + ) + return bundle_copy + + +def sandbox_event(resource: str, query_string_parameters: dict, multi_value_query_string_parameters: dict | None = None) -> dict: + return { + "resource": resource, + "queryStringParameters": query_string_parameters, + "multiValueQueryStringParameters": multi_value_query_string_parameters or { + key: [value] if not isinstance(value, list) else value + for key, value in query_string_parameters.items() + }, + } + + +def test_sandbox_practitioner_basic_matches_spec_example(): + response = lambda_handler( + sandbox_event("/Practitioner", {"identifier": SANDBOX_UUID}), + LambdaContext(), + ) + + assert response["statusCode"] == 200 + assert sort_bundle_entries(json.loads(response["body"])) == sort_bundle_entries( + load_example("GetHealthcareWorkerDetailsResponseSuccessBasic.json") + ) + + +def test_sandbox_practitioner_with_revinclude_matches_spec_example(): + response = lambda_handler( + sandbox_event( + "/Practitioner", + {"identifier": SANDBOX_UUID, "_revinclude": "PractitionerRole:practitioner"}, + ), + LambdaContext(), + ) + + assert response["statusCode"] == 200 + assert sort_bundle_entries(json.loads(response["body"])) == sort_bundle_entries( + load_example("GetHealthcareWorkerDetailsResponseSuccessWithIncludes.json") + ) + + +def test_sandbox_practitioner_role_basic_matches_spec_example(): + response = lambda_handler( + sandbox_event("/PractitionerRole", {"practitioner.identifier": SANDBOX_UUID}), + LambdaContext(), + ) + + assert response["statusCode"] == 200 + assert sort_bundle_entries(json.loads(response["body"])) == sort_bundle_entries( + load_example("GetHealthcareWorkerRoleDetailsResponseSuccessBasic.json") + ) + + +def test_sandbox_practitioner_role_with_include_matches_spec_example(): + response = lambda_handler( + sandbox_event( + "/PractitionerRole", + {"practitioner.identifier": SANDBOX_UUID, "_include": "PractitionerRole:practitioner"}, + ), + LambdaContext(), + ) + + assert response["statusCode"] == 200 + assert sort_bundle_entries(json.loads(response["body"])) == sort_bundle_entries( + load_example("GetHealthcareWorkerRoleDetailsResponseSuccessWithIncludes.json") + ) + + +def test_sandbox_practitioner_unknown_uuid_returns_not_found(): + response = lambda_handler( + sandbox_event("/Practitioner", {"identifier": "999999999999"}), + LambdaContext(), + ) + + assert response["statusCode"] == 404 + assert json.loads(response["body"]) == { + "resourceType": "OperationOutcome", + "issue": [{ + "severity": "error", + "code": "unknown", + "details": { + "coding": [{ + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "404", + "display": "User with id 999999999999 not found", + }] + }, + }], + } + + +def test_sandbox_practitioner_role_unknown_uuid_returns_not_found(): + response = lambda_handler( + sandbox_event("/PractitionerRole", {"practitioner.identifier": "999999999999"}), + LambdaContext(), + ) + + assert response["statusCode"] == 404 + assert json.loads(response["body"]) == { + "resourceType": "OperationOutcome", + "issue": [{ + "severity": "error", + "code": "unknown", + "details": { + "coding": [{ + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "404", + "display": "User with id 999999999999 not found", + }] + }, + }], + } diff --git a/src/request_handlers/sandbox_static_responses.py b/src/request_handlers/sandbox_static_responses.py new file mode 100644 index 00000000..024f57ec --- /dev/null +++ b/src/request_handlers/sandbox_static_responses.py @@ -0,0 +1,77 @@ +import json +from pathlib import Path + +from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent + +SANDBOX_UUID = "123456789012" +EXAMPLES_SUBPATH = Path("specification") / "components" / "examples" + + +def get_sandbox_response(event: APIGatewayProxyEvent) -> tuple[int, str] | None: + query_parameters = event.query_string_parameters or {} + + if event.resource == "/Practitioner": + return _get_practitioner_response(query_parameters) + + if event.resource == "/PractitionerRole": + return _get_practitioner_role_response(query_parameters) + + return None + + +def _get_practitioner_response(query_parameters: dict[str, str]) -> tuple[int, str]: + practitioner_id = query_parameters.get("identifier") + if practitioner_id != SANDBOX_UUID: + return _not_found_response(practitioner_id) + + if query_parameters.get("_revinclude") == "PractitionerRole:practitioner": + return 200, _load_example("GetHealthcareWorkerDetailsResponseSuccessWithIncludes.json") + + return 200, _load_example("GetHealthcareWorkerDetailsResponseSuccessBasic.json") + + +def _get_practitioner_role_response(query_parameters: dict[str, str]) -> tuple[int, str]: + practitioner_id = query_parameters.get("practitioner.identifier") + if practitioner_id != SANDBOX_UUID: + return _not_found_response(practitioner_id) + + if query_parameters.get("_include") == "PractitionerRole:practitioner": + return 200, _load_example("GetHealthcareWorkerRoleDetailsResponseSuccessWithIncludes.json") + + return 200, _load_example("GetHealthcareWorkerRoleDetailsResponseSuccessBasic.json") + + +def _not_found_response(practitioner_id: str | None) -> tuple[int, str]: + response = { + "resourceType": "OperationOutcome", + "issue": [{ + "severity": "error", + "code": "unknown", + "details": { + "coding": [{ + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "404", + "display": f"User with id {practitioner_id} not found", + }] + }, + }], + } + + return 404, json.dumps(response) + + +def _load_example(file_name: str) -> str: + with open(_get_examples_directory() / file_name, encoding="utf-8") as example_file: + return example_file.read() + + +def _get_examples_directory() -> Path: + current_file = Path(__file__).resolve() + candidate_roots = [current_file.parents[2], current_file.parents[1]] + + for root in candidate_roots: + examples_directory = root / EXAMPLES_SUBPATH + if examples_directory.exists(): + return examples_directory + + raise FileNotFoundError(f"Could not locate sandbox example files under {EXAMPLES_SUBPATH}") \ No newline at end of file diff --git a/src/sandbox_main.py b/src/sandbox_main.py new file mode 100644 index 00000000..1857b589 --- /dev/null +++ b/src/sandbox_main.py @@ -0,0 +1,56 @@ +import json +from datetime import datetime + +from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent +from aws_lambda_powertools.utilities.typing import LambdaContext +from pydantic.schema import timedelta + +from logs.log import Log +from main import lambda_handler as default_lambda_handler +from request_handlers.sandbox_static_responses import get_sandbox_response + +logger = Log("sandbox_main") +STATUS_ENDPOINTS = {"/", "/_status"} + + +def lambda_handler(event_dict: dict, context: LambdaContext) -> dict: + start_time = datetime.now() + event = APIGatewayProxyEvent(event_dict) + if event.resource in STATUS_ENDPOINTS: + return default_lambda_handler(event_dict, context) + + sandbox_response = get_sandbox_response(event) + + if sandbox_response: + status_code, body = sandbox_response + full_response = { + "isBase64Encoded": False, + "statusCode": status_code, + "headers": {"Content-Type": "application/json"}, + "body": body, + } + end_time = datetime.now() + debug_timing = {"ms": int((end_time - start_time) / timedelta(milliseconds=1))} + logger.info(f"Sending sandbox response after {json.dumps(debug_timing)}", "RESP_SENT_TIME", "null") + Log.cleanup() + return full_response + + return { + "isBase64Encoded": False, + "statusCode": 404, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps({ + "resourceType": "OperationOutcome", + "issue": [{ + "severity": "error", + "code": "unknown", + "details": { + "coding": [{ + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "404", + "display": f"There is no defined handler for the provided endpoint {event.resource}", + }] + }, + }], + }), + } \ No newline at end of file