Skip to content

Commit fe13922

Browse files
[NDR-253] Only search PDM table on requests from PDM (#812)
Co-authored-by: Megan <[email protected]>
1 parent acf483a commit fe13922

12 files changed

+205
-65
lines changed

lambdas/handlers/fhir_document_reference_search_handler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
6363
return_fhir=True,
6464
additional_filters=search_filters,
6565
check_upload_completed=False,
66+
api_request_context=event.get("requestContext", {}),
6667
)
6768

6869
if not document_references:

lambdas/services/document_reference_search_service.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from enums.dynamo_filter import AttributeOperator
77
from enums.lambda_error import LambdaError
88
from enums.metadata_field_names import DocumentReferenceMetadataFields
9+
from enums.mtls import MtlsCommonNames
910
from enums.snomed_codes import SnomedCodes
1011
from models.document_reference import DocumentReference
1112
from models.fhir.R4.bundle import Bundle, BundleEntry
@@ -17,6 +18,7 @@
1718
from utils.dynamo_query_filter_builder import DynamoQueryFilterBuilder
1819
from utils.exceptions import DynamoServiceException
1920
from utils.lambda_exceptions import DocumentRefSearchException
21+
from utils.lambda_header_utils import validate_common_name_in_mtls
2022

2123
logger = LoggingService(__name__)
2224

@@ -28,6 +30,7 @@ def get_document_references(
2830
return_fhir: bool = False,
2931
additional_filters=None,
3032
check_upload_completed=True,
33+
api_request_context: dict = {},
3134
):
3235
"""
3336
Fetch document references for a given NHS number.
@@ -38,8 +41,11 @@ def get_document_references(
3841
:param check_upload_completed: If True, check if the upload is completed before returning the results.
3942
:return: List of document references or FHIR DocumentReferences.
4043
"""
44+
common_name = validate_common_name_in_mtls(
45+
api_request_context=api_request_context
46+
)
4147
try:
42-
list_of_table_names = self._get_table_names()
48+
list_of_table_names = self._get_table_names(common_name)
4349
results = self._search_tables_for_documents(
4450
nhs_number,
4551
list_of_table_names,
@@ -60,13 +66,18 @@ def get_document_references(
6066
)
6167
raise DocumentRefSearchException(500, LambdaError.DocRefClient)
6268

63-
def _get_table_names(self) -> list[str]:
69+
def _get_table_names(self, common_name: MtlsCommonNames | None) -> list[str]:
70+
table_list = []
6471
try:
65-
return json.loads(os.environ["DYNAMODB_TABLE_LIST"])
72+
table_list = json.loads(os.environ["DYNAMODB_TABLE_LIST"])
6673
except JSONDecodeError as e:
6774
logger.error(f"Failed to decode table list: {str(e)}")
6875
raise
6976

77+
if not common_name or common_name not in MtlsCommonNames:
78+
return table_list
79+
return [t for t in table_list if common_name.value.lower() in t.lower()]
80+
7081
def _search_tables_for_documents(
7182
self,
7283
nhs_number: str,
@@ -197,7 +208,7 @@ def create_document_reference_fhir_response(
197208
or document_reference.created,
198209
url=document_retrieve_endpoint
199210
+ "/"
200-
+ SnomedCodes.LLOYD_GEORGE.value.code
211+
+ document_reference.document_snomed_code_type
201212
+ "~"
202213
+ document_reference.id,
203214
)

lambdas/services/post_fhir_document_reference_service.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from botocore.exceptions import ClientError
77
from enums.lambda_error import LambdaError
88
from enums.mtls import MtlsCommonNames
9-
from enums.patient_ods_inactive_status import PatientOdsInactiveStatus
109
from enums.snomed_codes import SnomedCode, SnomedCodes
1110
from models.document_reference import DocumentReference
1211
from models.fhir.R4.fhir_document_reference import SNOMED_URL, Attachment
@@ -28,7 +27,6 @@
2827
)
2928
from utils.lambda_exceptions import CreateDocumentRefException
3029
from utils.lambda_header_utils import validate_common_name_in_mtls
31-
from utils.ods_utils import PCSE_ODS_CODE
3230
from utils.utilities import create_reference_id, get_pds_service, validate_nhs_number
3331

3432
logger = LoggingService(__name__)

lambdas/tests/e2e/api/fhir/conftest.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import os
2+
import shutil
3+
import subprocess
4+
import tempfile
25
import time
36

47
import pytest
58
import requests
6-
from syrupy.extensions.json import JSONSnapshotExtension
79

810
from lambdas.tests.e2e.helpers.pdm_data_helper import PdmDataHelper
911

@@ -45,12 +47,39 @@ def fetch_with_retry_mtls(
4547
raise Exception("Condition not met within retry limit")
4648

4749

48-
@pytest.fixture
49-
def snapshot_json(snapshot):
50-
return snapshot.with_defaults(extension_class=JSONSnapshotExtension)
51-
52-
5350
def create_mtls_session():
5451
session = requests.Session()
5552
session.cert = (CLIENT_CERT_PATH, CLIENT_KEY_PATH)
5653
return session
54+
55+
56+
@pytest.fixture
57+
def temp_cert_and_key():
58+
import os
59+
60+
temp_dir = tempfile.mkdtemp()
61+
key_path = os.path.join(temp_dir, "temp_key.pem")
62+
cert_path = os.path.join(temp_dir, "temp_cert.pem")
63+
64+
try:
65+
subprocess.run(["openssl", "genrsa", "-out", key_path, "2048"], check=True)
66+
subprocess.run(
67+
[
68+
"openssl",
69+
"req",
70+
"-new",
71+
"-x509",
72+
"-key",
73+
key_path,
74+
"-out",
75+
cert_path,
76+
"-days",
77+
"1",
78+
"-subj",
79+
"/C=GB/O=Test Org/CN=localhost",
80+
],
81+
check=True,
82+
)
83+
yield cert_path, key_path
84+
finally:
85+
shutil.rmtree(temp_dir)

lambdas/tests/e2e/api/fhir/test_retrieve_document_fhir_api.py

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
import io
23
import uuid
34

@@ -56,52 +57,68 @@ def test_file_retrieval(test_data, file_size):
5657
response = get_document_reference(pdm_record["id"])
5758
assert response.status_code == 200
5859

59-
if file_size:
60-
json = response.json()
60+
json = response.json()
61+
62+
if not file_size:
63+
data = json["content"][0]["attachment"]["data"]
64+
decoded_data = base64.b64decode(data)
65+
expected_bytes = b"Sample PDF Content"
66+
assert decoded_data == expected_bytes
67+
else:
6168
expected_presign_uri = (
6269
f"https://{PDM_S3_BUCKET}.s3.eu-west-2.amazonaws.com/"
6370
f"{pdm_record['nhs_number']}/{pdm_record['id']}"
6471
)
6572
assert expected_presign_uri in json["content"][0]["attachment"]["url"]
6673

6774

68-
def test_no_file_found():
69-
"""Test retrieval when file does not exist."""
70-
pdm_record = build_pdm_record()
71-
response = get_document_reference(pdm_record["id"])
72-
assert response.status_code == 404
75+
@pytest.mark.parametrize(
76+
"nhs_id,expected_status,expected_code,expected_diagnostics",
77+
[
78+
("9912003071", 404, "RESOURCE_NOT_FOUND", "Document reference not found"),
79+
],
80+
)
81+
def test_retrieve_edge_cases(
82+
nhs_id, expected_status, expected_code, expected_diagnostics
83+
):
84+
response = get_document_reference(nhs_id)
85+
assert response.status_code == expected_status
86+
87+
body = response.json()
88+
issue = body["issue"][0]
89+
90+
details = issue.get("details", {})
91+
coding = details.get("coding", [{}])[0]
92+
93+
assert coding.get("code") == expected_code
94+
assert issue.get("diagnostics") == expected_diagnostics
7395

7496

7597
def test_preliminary_file(test_data):
76-
"""Test retrieval for preliminary document status."""
7798
pdm_record = build_pdm_record(doc_status="preliminary")
7899
test_data.append(pdm_record)
79-
80100
pdm_data_helper.create_metadata(pdm_record)
81101
pdm_data_helper.create_resource(pdm_record)
82102

83103
response = get_document_reference(pdm_record["id"])
84104
assert response.status_code == 200
85105

106+
response_json = response.json()
107+
assert response_json.get("docStatus") == "preliminary"
86108

87-
# the error is about the auth token not mtls?
88-
def test_forbidden_without_mtls(test_data):
109+
110+
def test_forbidden_with_invalid_cert(test_data, temp_cert_and_key):
89111
pdm_record = build_pdm_record()
90112
test_data.append(pdm_record)
91-
92113
pdm_data_helper.create_metadata(pdm_record)
93114
pdm_data_helper.create_resource(pdm_record)
94115

95-
url = (
96-
f"https://{MTLS_ENDPOINT}/FhirDocumentReference/{PDM_SNOMED}~{pdm_record['id']}"
97-
)
98-
headers = {
99-
"Authorization": "Bearer 123",
100-
"X-Correlation-Id": "1234",
101-
}
102-
response = requests.request("GET", url, headers=headers)
103-
print("inital response:", response)
104-
json = response.json()
105-
print("response:", json)
116+
# Use an invalid cert that is trusted by TLS but fails truststore validation
117+
cert_path, key_path = temp_cert_and_key
118+
url = f"https://{MTLS_ENDPOINT}/DocumentReference/{PDM_SNOMED}~{pdm_record['id']}"
119+
headers = {"Authorization": "Bearer 123", "X-Correlation-Id": "1234"}
106120

121+
response = requests.get(url, headers=headers, cert=(cert_path, key_path))
122+
body = response.json()
107123
assert response.status_code == 403
124+
assert body["message"] == "Forbidden"

lambdas/tests/e2e/api/fhir/test_search_patient_fhir_api.py

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import io
2-
import logging
32
import uuid
43

54
import pytest
5+
import requests
66

7-
from lambdas.tests.e2e.api.fhir.conftest import MTLS_ENDPOINT, create_mtls_session
7+
from lambdas.tests.e2e.api.fhir.conftest import (
8+
MTLS_ENDPOINT,
9+
PDM_SNOMED,
10+
create_mtls_session,
11+
)
12+
from lambdas.tests.e2e.conftest import APIM_ENDPOINT
813
from lambdas.tests.e2e.helpers.pdm_data_helper import PdmDataHelper
914

1015
pdm_data_helper = PdmDataHelper()
@@ -46,33 +51,76 @@ def create_and_store_record(test_data, nhs_number="9912003071", doc_status=None)
4651

4752

4853
def test_search_patient_details(test_data):
49-
"""Search for a patient with one record."""
5054
create_and_store_record(test_data)
55+
5156
response = search_document_reference("9912003071")
5257
assert response.status_code == 200
58+
5359
bundle = response.json()
54-
logging.info(bundle)
55-
assert "entry" in bundle # Basic validation
60+
assert "entry" in bundle
61+
62+
attachment_url = bundle["entry"][0]["resource"]["content"][0]["attachment"]["url"]
63+
assert (
64+
f"https://{APIM_ENDPOINT}/national-document-repository/DocumentReference/{PDM_SNOMED}~"
65+
in attachment_url
66+
)
5667

5768

5869
def test_multiple_cancelled_search_patient_details(test_data):
59-
"""Search for a patient with multiple cancelled records."""
6070
create_and_store_record(test_data, doc_status="cancelled")
6171
create_and_store_record(test_data, doc_status="cancelled")
72+
6273
response = search_document_reference("9912003071")
6374
assert response.status_code == 200
75+
6476
bundle = response.json()
65-
assert len(bundle.get("entry", [])) >= 2
77+
entries = bundle.get("entry", [])
78+
assert len(entries) >= 2
79+
80+
# Assert that all entries have status "cancelled"
81+
for entry in entries:
82+
resource = entry.get("resource", {})
83+
assert resource.get("docStatus") == "cancelled"
6684

6785

6886
@pytest.mark.parametrize(
69-
"nhs_number,expected_status",
87+
"nhs_number,expected_status,expected_code,expected_diagnostics",
7088
[
71-
("9912003071", 404), # No records
72-
("9999999993", 400), # Invalid patient
89+
("9912003071", 404, "RESOURCE_NOT_FOUND", "Document reference not found"),
90+
("9999999993", 400, "INVALID_SEARCH_DATA", "Invalid patient number 9999999993"),
91+
("123", 400, "INVALID_SEARCH_DATA", "Invalid patient number 123"),
7392
],
7493
)
75-
def test_search_edge_cases(nhs_number, expected_status):
76-
"""Test search for no records and invalid patient."""
94+
def test_search_edge_cases(
95+
nhs_number, expected_status, expected_code, expected_diagnostics
96+
):
7797
response = search_document_reference(nhs_number)
7898
assert response.status_code == expected_status
99+
100+
body = response.json()
101+
issue = body["issue"][0]
102+
details = issue.get("details", {})
103+
coding = details.get("coding", [{}])[0]
104+
assert coding.get("code") == expected_code
105+
assert issue.get("diagnostics") == expected_diagnostics
106+
107+
108+
def test_search_patient_unauthorized_mtls(test_data, temp_cert_and_key):
109+
"""Search should return 403 when mTLS certificate is invalid or missing."""
110+
create_and_store_record(test_data)
111+
url = (
112+
f"https://{MTLS_ENDPOINT}/DocumentReference?"
113+
f"subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9912003071"
114+
)
115+
headers = {
116+
"Authorization": "Bearer 123",
117+
"X-Correlation-Id": "unauthorized-test",
118+
}
119+
120+
# Use an invalid cert that is trusted by TLS but fails truststore validation
121+
cert_path, key_path = temp_cert_and_key
122+
123+
response = requests.get(url, headers=headers, cert=(cert_path, key_path))
124+
body = response.json()
125+
assert response.status_code == 403
126+
assert body["message"] == "Forbidden"

lambdas/tests/unit/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ def set_env(monkeypatch):
149149
monkeypatch.setenv(MOCK_PDM_TABLE_NAME_ENV_NAME, MOCK_PDM_TABLE_NAME)
150150
monkeypatch.setenv(MOCK_PDM_BUCKET_ENV_NAME, MOCK_PDM_BUCKET)
151151
monkeypatch.setenv(
152-
"DYNAMODB_TABLE_LIST", json.dumps([MOCK_ARF_TABLE_NAME, MOCK_LG_TABLE_NAME])
152+
"DYNAMODB_TABLE_LIST", json.dumps([MOCK_PDM_TABLE_NAME, MOCK_LG_TABLE_NAME])
153153
)
154154
monkeypatch.setenv(MOCK_ZIP_OUTPUT_BUCKET_ENV_NAME, MOCK_ZIP_OUTPUT_BUCKET)
155155
monkeypatch.setenv(MOCK_ZIP_TRACE_TABLE_ENV_NAME, MOCK_ZIP_TRACE_TABLE)

lambdas/tests/unit/handlers/test_fhir_document_reference_search_handler.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def test_lambda_handler_returns_200_with_documents(
147147
return_fhir=True,
148148
additional_filters={},
149149
check_upload_completed=False,
150+
api_request_context={},
150151
)
151152

152153

@@ -163,6 +164,7 @@ def test_lambda_handler_returns_404_when_no_documents(
163164
return_fhir=True,
164165
additional_filters={},
165166
check_upload_completed=False,
167+
api_request_context={},
166168
)
167169

168170

@@ -208,6 +210,7 @@ def test_lambda_handler_with_additional_filters(
208210
return_fhir=True,
209211
additional_filters=expected_filters,
210212
check_upload_completed=False,
213+
api_request_context={},
211214
)
212215

213216

0 commit comments

Comments
 (0)