Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/preview-env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ jobs:
APIM_MTLS_KEY_NAME=$MTLS_KEY, \
APIM_KEY_ID=$KEY_ID, \
APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \
PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \
PDM_BUNDLE_URL=$MOCK_URL/pdm/FHIR/R4/Bundle, \
MNS_EVENT_URL=$MOCK_URL/mns/events, \
CLIENT_TIMEOUT=$CLIENT_TIMEOUT, \
JWKS_SECRET_NAME=$JWKS_SECRET}" || true
Expand All @@ -205,7 +205,7 @@ jobs:
APIM_MTLS_CERT_NAME=$MTLS_CERT, \
APIM_MTLS_KEY_NAME=$MTLS_KEY, \
APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \
PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \
PDM_BUNDLE_URL=$MOCK_URL/pdm/FHIR/R4/Bundle, \
MNS_EVENT_URL=$MOCK_URL/mns/events, \
CLIENT_TIMEOUT=$CLIENT_TIMEOUT, \
JWKS_SECRET_NAME=$JWKS_SECRET}" \
Expand Down
1 change: 1 addition & 0 deletions infrastructure/images/pathology-api/resources/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/build
/.aws
10 changes: 3 additions & 7 deletions mocks/src/apim_mock/auth_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,14 @@
from typing import Any

from boto3.dynamodb.conditions import Attr
from common.logging import get_logger
from common.storage_helper import StorageHelper

JWT_ALGORITHMS = ["RS512"]
REQUESTS_TIMEOUT = 5
DEFAULT_TOKEN_LIFETIME = 599

AUTH_URL = os.environ["AUTH_URL"]
PUBLIC_KEY_URL = os.environ["PUBLIC_KEY_URL"]
API_KEY = os.environ["API_KEY"]
TOKEN_TABLE_NAME = os.environ["TOKEN_TABLE_NAME"]
BRANCH_NAME = os.environ["DDB_INDEX_TAG"]

storage_helper = StorageHelper(TOKEN_TABLE_NAME, BRANCH_NAME)
_logger = get_logger(__name__)


class AuthenticationError(Exception):
Expand All @@ -24,6 +19,7 @@ class AuthenticationError(Exception):
def check_authenticated(request_headers: dict[str, Any]) -> None:
auth_token = request_headers.get("Authorization", "").replace("Bearer ", "")

_logger.debug("Querying DynamoDB table for access token")
filter_expression = Attr("access_token").eq(auth_token)
query_result = storage_helper.find_items(filter_expression)

Expand Down
11 changes: 2 additions & 9 deletions mocks/src/apim_mock/handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
import os
import re
import secrets
import string
from datetime import datetime, timedelta, timezone
Expand All @@ -15,6 +14,7 @@
from aws_lambda_powertools.event_handler.router import APIGatewayHttpRouter
from common.logging import get_logger
from common.storage_helper import BaseMockItem, StorageHelper
from common.utils import check_valid_uuid4
from requests import HTTPError

JWT_ALGORITHMS = ["RS512"]
Expand Down Expand Up @@ -156,7 +156,7 @@ def _validate_assertions(assertions: dict[str, Any]) -> None:
if not jti:
raise ValueError("Missing 'jti' claim in client_assertion JWT")

if not _check_valid_uuid4(jti):
if not check_valid_uuid4(jti):
raise ValueError("Invalid UUID4 value for jti")

if not assertions.get("exp"):
Expand All @@ -171,13 +171,6 @@ def _validate_assertions(assertions: dict[str, Any]) -> None:
)


def _check_valid_uuid4(string: str) -> bool:
uuid_regex = (
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
return re.match(uuid_regex, string) is not None


def _generate_random_token() -> str:
return "".join(
secrets.choice(
Expand Down
9 changes: 9 additions & 0 deletions mocks/src/common/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from common.utils import check_valid_uuid4


class TestUtils:
def test_check_valid_uuid_with_valid_uuid(self) -> None:
assert check_valid_uuid4("8c64be5f-3d7a-4b7b-8260-b716d122bdaf")

def test_check_valid_uuid_with_invalid_uuid(self) -> None:
assert not check_valid_uuid4("invalid-uuid")
8 changes: 8 additions & 0 deletions mocks/src/common/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import re


def check_valid_uuid4(string: str) -> bool:
uuid_regex = (
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
return re.match(uuid_regex, string) is not None
32 changes: 30 additions & 2 deletions mocks/src/pdm_mock/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from aws_lambda_powertools.event_handler.router import APIGatewayHttpRouter
from common.logging import get_logger
from common.storage_helper import BaseMockItem, StorageHelper
from common.utils import check_valid_uuid4

PDM_TABLE_NAME = os.environ["PDM_TABLE_NAME"]
BRANCH_NAME = os.environ["DDB_INDEX_TAG"]
Expand Down Expand Up @@ -93,8 +94,11 @@ def _fetch_patient_from_payload(payload: dict[str, Any]) -> str | None:

def handle_post_request(payload: dict[str, Any]) -> PDMResponse:
if (patient := _fetch_patient_from_payload(payload)) in REQUEST_HANDLERS:
_logger.debug("Using magic patient id bypass, %s", patient)
return REQUEST_HANDLERS[patient]()

_logger.debug("Not using magic patient id bypass")

document_id = str(uuid4())
created_document = {
**payload,
Expand All @@ -115,7 +119,7 @@ def handle_post_request(payload: dict[str, Any]) -> PDMResponse:

_write_document_to_table(item)

return {"status_code": 200, "response": created_document}
return {"status_code": 201, "response": created_document}


def handle_get_request(document_id: str) -> PDMResponse:
Expand All @@ -127,10 +131,12 @@ def handle_get_request(document_id: str) -> PDMResponse:


def _write_document_to_table(item: DocumentItem) -> None:
_logger.debug("Writing document to dynamodb table")
storage_helper.put_item(item)


def _get_document_from_table(document_id: str) -> DocumentItem:
_logger.debug("Retrieving document from dynamodb table")
item = storage_helper.get_item_by_session_id(document_id)
return cast("DocumentItem", item)

Expand All @@ -151,6 +157,20 @@ def create_document() -> Response[str]:

check_authenticated(request_headers)

_logger.debug("Passed Auth Check")

x_request_id = request_headers.get("X-Request-ID")
if not x_request_id:
_logger.error("Missing X-Request-ID header.")
return _with_default_headers(
_create_operation_outcome(400, "Missing X-Request-ID header", "required")
)
if not check_valid_uuid4(x_request_id):
_logger.error("Invalid X-Request-ID header. Value provided: %s", x_request_id)
return _with_default_headers(
_create_operation_outcome(400, "Invalid X-Request-ID header", "invalid")
)

try:
payload = pdm_routes.current_event.json_body
except json.JSONDecodeError as err:
Expand All @@ -174,7 +194,15 @@ def create_document() -> Response[str]:
_logger.exception("Error handling PDM request")
return Response(status_code=500, body=json.dumps({"error": str(err)}))

return _with_default_headers(response)
return Response(
body=json.dumps(response["response"]),
status_code=response["status_code"],
headers={
"Content-Type": "application/fhir+json",
"x-request-id": x_request_id,
"etag": 'W/"1"',
},
)


@pdm_routes.get("/pdm/mock/Bundle/<document_id>")
Expand Down
54 changes: 51 additions & 3 deletions mocks/src/pdm_mock/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def test_handle_post_request(
response = handler.handle_post_request(basic_document_payload)

assert response == {
"status_code": 200,
"status_code": 201,
"response": {
"resourceType": "Bundle",
"id": "uuid4",
Expand Down Expand Up @@ -308,14 +308,58 @@ def test_create_document(
event = self._create_test_event(
path_params="pdm/FHIR/R4/Bundle",
request_method="POST",
headers={"X-Request-ID": "8c64be5f-3d7a-4b7b-8260-b716d122bdaf"},
body=json.dumps({"test": "data"}),
)
context = LambdaContext()

with patch("boto3.resource"):
response = lambda_app.resolve(event, context)

assert response["statusCode"] == 200
assert response["statusCode"] == 201

@pytest.mark.parametrize(
("headers", "expected_issue_code", "expected_error_message"),
[
pytest.param({}, "required", "Missing X-Request-ID header"),
pytest.param(
{"X-Request-ID": "invalid"}, "invalid", "Invalid X-Request-ID header"
),
],
)
@patch("pdm_mock.handler.check_authenticated")
def test_pdm_invalid_or_missing_x_request_id(
self,
check_authenticated_mock: MagicMock,
headers: dict[str, str],
expected_issue_code: str,
expected_error_message: str,
lambda_app: APIGatewayHttpResolver,
) -> None:
check_authenticated_mock.return_value = True

event = self._create_test_event(
path_params="pdm/FHIR/R4/Bundle",
request_method="POST",
headers=headers,
body=json.dumps({"test": "data"}),
)
context = LambdaContext()
response = lambda_app.resolve(event, context)

assert response["statusCode"] == 400
assert json.loads(response["body"]) == {
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": expected_issue_code,
"details": {
"text": expected_error_message,
},
}
],
}

@patch("pdm_mock.handler.check_authenticated")
def test_pdm_mock_failed_authentication(
Expand All @@ -328,10 +372,11 @@ def test_pdm_mock_failed_authentication(
event = self._create_test_event(
path_params="pdm/FHIR/R4/Bundle",
request_method="POST",
headers={"X-Request-ID": "8c64be5f-3d7a-4b7b-8260-b716d122bdaf"},
body=json.dumps({"test": "data"}),
)
context = LambdaContext()
with pytest.raises(AuthenticationError, match=""):
with pytest.raises(AuthenticationError, match=r"^$"):
lambda_app.resolve(event, context)

@patch("pdm_mock.handler.check_authenticated")
Expand All @@ -345,6 +390,7 @@ def test_create_document_invalid_body(
event = self._create_test_event(
path_params="pdm/FHIR/R4/Bundle",
request_method="POST",
headers={"X-Request-ID": "8c64be5f-3d7a-4b7b-8260-b716d122bdaf"},
body="Invalid Body",
)
context = LambdaContext()
Expand Down Expand Up @@ -376,6 +422,7 @@ def test_create_document_invalid_payload(
event = self._create_test_event(
path_params="pdm/FHIR/R4/Bundle",
request_method="POST",
headers={"X-Request-ID": "8c64be5f-3d7a-4b7b-8260-b716d122bdaf"},
body="",
)
context = LambdaContext()
Expand Down Expand Up @@ -412,6 +459,7 @@ def test_pdm_mock_create_document_internal_server_error(
event = self._create_test_event(
path_params="pdm/FHIR/R4/Bundle",
request_method="POST",
headers={"X-Request-ID": "8c64be5f-3d7a-4b7b-8260-b716d122bdaf"},
body=json.dumps({"test": "data"}),
)

Expand Down
44 changes: 37 additions & 7 deletions pathology-api/lambda_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from functools import reduce
from json import JSONDecodeError
from typing import Any
from uuid import uuid4

import pydantic
from aws_lambda_powertools.event_handler import (
Expand All @@ -14,7 +15,11 @@
from pathology_api.handler import handle_request
from pathology_api.logging import get_logger
from pathology_api.mns import MnsException
from pathology_api.request_context import reset_correlation_id, set_correlation_id
from pathology_api.pdm import PdmException
from pathology_api.request_context import (
reset_correlation_id,
set_correlation_id,
)

_logger = get_logger(__name__)
_CORRELATION_ID_HEADER = "nhsd-correlation-id"
Expand Down Expand Up @@ -112,6 +117,15 @@ def handle_exception(exception: Exception) -> Response[str]:
)


@_exception_handler(PdmException)
def handle_pdm_excepton(exception: PdmException) -> Response[str]:
_logger.exception("PDMClientError encountered: %s", exception)
return _with_default_headers(
status_code=500,
body=OperationOutcome.create_validation_error(exception.message),
)


@_exception_handler(MnsException)
def handle_mns_exception(exception: MnsException) -> Response[str]:
_logger.exception("Failed to publish MNS event: %s", exception)
Expand All @@ -123,6 +137,13 @@ def handle_mns_exception(exception: MnsException) -> Response[str]:

@app.get("/_status")
def status() -> Response[str]:
pathology_api_correlation_id = str(uuid4())

set_correlation_id(
full_id=pathology_api_correlation_id,
short_id=pathology_api_correlation_id,
)

_logger.debug("Status check endpoint called")
return Response(
status_code=200,
Expand All @@ -133,12 +154,20 @@ def status() -> Response[str]:

@app.post("/FHIR/R4/Bundle")
def post_result() -> Response[str]:
correlation_id = app.current_event.headers.get(_CORRELATION_ID_HEADER)
correlation_id_header = app.current_event.headers.get(_CORRELATION_ID_HEADER)

if not correlation_id:
pathology_api_correlation_id = str(uuid4())
if not correlation_id_header:
set_correlation_id(
full_id=pathology_api_correlation_id,
short_id=pathology_api_correlation_id,
)
raise ValueError(f"Missing required header: {_CORRELATION_ID_HEADER}")

set_correlation_id(correlation_id)
set_correlation_id(
full_id=f"{correlation_id_header}.{pathology_api_correlation_id}",
short_id=pathology_api_correlation_id,
)
_logger.debug("Post result endpoint called.")

try:
Expand All @@ -155,11 +184,12 @@ def post_result() -> Response[str]:

bundle = Bundle.model_validate(payload, by_alias=True)

response = handle_request(bundle)
pdm_response = handle_request(bundle)

return _with_default_headers(
return Response(
status_code=200,
body=response,
headers={"Content-Type": "application/fhir+json", "etag": pdm_response.etag},
body=pdm_response.bundle.model_dump_json(by_alias=True, exclude_none=True),
)


Expand Down
Loading
Loading