Skip to content

Commit a89e9e1

Browse files
CCM-14566: Add FHIR validation to mesh-download (#276)
* CCM-14566: Add FHIR validation to mesh-download * CCM-14566: Update component tests --------- Co-authored-by: Gareth Allan <157592212+gareth-allan@users.noreply.github.com>
1 parent cc997d2 commit a89e9e1

8 files changed

Lines changed: 295 additions & 19 deletions

File tree

infrastructure/terraform/components/dl/data/failure_codes.csv

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ code,description
22
DL_PDMV_001,Letter rejected by PDM
33
DL_PDMV_002,Timeout waiting for letter storage
44
DL_CLIV_003,Attachment contains a virus
5+
DL_CLIV_004,Duplicate request
6+
DL_CLIV_005,Invalid FHIR resource
57
DL_INTE_001,Request rejected by Core API

infrastructure/terraform/components/dl/s3_object_failure_codes.tf

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
# Auto-generated CSV containing failure code definitions
2-
# Source: src/digital-letters-events/failure-codes.ts
3-
# Build: make build / make generate (runs generate-dependencies)
41
resource "aws_s3_object" "failure_codes" {
52
bucket = module.s3bucket_reporting.bucket
63
key = "reference-data/failure_codes/failure_codes.csv"

lambdas/mesh-download/mesh_download/__tests__/test_processor.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,58 @@ def create_sqs_record(cloud_event=None):
7070
'body': json.dumps({'detail': cloud_event})
7171
}
7272

73+
def create_fhir_content():
74+
"""
75+
Create a mock FHIR JSON content for testing
76+
"""
77+
return json.dumps({
78+
"resourceType": "DocumentReference",
79+
"id": "82bfb7f3-4889-4e15-b308-bbe4e3cd431f",
80+
"status": "current",
81+
"docStatus": "final",
82+
"type": {
83+
"coding": [
84+
{
85+
"system": "http://snomed.info/sct",
86+
"code": "308540004",
87+
"display": "Appointment"
88+
}
89+
]
90+
},
91+
"subject": {
92+
"identifier": {
93+
"system": "https://fhir.nhs.uk/Id/nhs-number",
94+
"value": "9876543210"
95+
}
96+
},
97+
"author": [
98+
{
99+
"identifier": {
100+
"system": "https://fhir.nhs.uk/Id/ods-organization-code",
101+
"value": "RX809"
102+
},
103+
"display": "Example NHS Trust"
104+
}
105+
],
106+
"custodian": {
107+
"identifier": {
108+
"system": "https://fhir.nhs.uk/Id/ods-organization-code",
109+
"value": "C4L8E"
110+
},
111+
"display": "NHS ENGLAND: NHS NOTIFY"
112+
},
113+
"date": "2025-11-19T14:30:00Z",
114+
"description": "Appointment notification letter for outpatient consultation",
115+
"content": [
116+
{
117+
"attachment": {
118+
"contentType": "application/pdf",
119+
"title": "Appointment Letter - November 2025",
120+
"data": "base64here=="
121+
}
122+
}
123+
]
124+
})
73125

74126
def create_mesh_message(message_id='test_123', sender='SENDER_001', local_id='ref_001'):
75127
"""
@@ -82,8 +134,9 @@ def create_mesh_message(message_id='test_123', sender='SENDER_001', local_id='re
82134
message.subject = 'test_document.pdf'
83135
message.workflow_id = 'TEST_WORKFLOW'
84136
message.message_type = 'DATA'
85-
message.read.return_value = b'Test message content'
137+
message.read.return_value = create_fhir_content()
86138
message.acknowledge = Mock()
139+
87140
return message
88141

89142

@@ -148,7 +201,7 @@ def test_process_sqs_message_success(self, mock_datetime):
148201
sender_id='TEST-SENDER',
149202
message_reference='ref-001',
150203
mesh_message_id='test-message-123',
151-
content=b'Test message content'
204+
content=create_fhir_content()
152205
)
153206

154207
mesh_message.acknowledge.assert_called_once()
@@ -183,6 +236,76 @@ def test_process_sqs_message_success(self, mock_datetime):
183236
assert event_data['messageUri'] == 's3://test-pii-bucket/document-reference/SENDER-001/ref-001_test-message-123'
184237
assert set(event_data.keys()) == {'senderId', 'messageReference', 'messageUri', 'meshMessageId'}
185238

239+
@patch('mesh_download.processor.datetime')
240+
def test_process_sqs_message_invalid_fhir_content(self, mock_datetime):
241+
from mesh_download.processor import MeshDownloadProcessor
242+
243+
config, log, event_publisher, document_store = setup_mocks()
244+
245+
fixed_time = datetime(2025, 11, 19, 15, 30, 45, tzinfo=timezone.utc)
246+
mock_datetime.now.return_value = fixed_time
247+
248+
document_store.store_document.return_value = 'document-reference/SENDER_001_ref_001'
249+
250+
event_publisher.send_events.return_value = []
251+
252+
processor = MeshDownloadProcessor(
253+
config=config,
254+
log=log,
255+
mesh_client=config.mesh_client,
256+
download_metric=config.download_metric,
257+
duplicate_download_metric=config.duplicate_download_metric,
258+
document_store=document_store,
259+
event_publisher=event_publisher
260+
)
261+
262+
mesh_message = create_mesh_message()
263+
mesh_message.read.return_value = '{}' # invalid FHIR content (empty JSON)}
264+
config.mesh_client.retrieve_message.return_value = mesh_message
265+
266+
sqs_record = create_sqs_record()
267+
268+
processor.process_sqs_message(sqs_record)
269+
270+
config.mesh_client.retrieve_message.assert_called_once_with('test-message-123')
271+
272+
mesh_message.read.assert_called_once()
273+
274+
document_store.store_document.assert_not_called()
275+
276+
mesh_message.acknowledge.assert_called_once()
277+
278+
config.download_metric.record.assert_not_called()
279+
280+
event_publisher.send_events.assert_called_once()
281+
282+
# Verify the published event content
283+
published_events = event_publisher.send_events.call_args[0][0]
284+
assert len(published_events) == 1
285+
286+
published_event = published_events[0]
287+
288+
# Verify CloudEvent envelope fields
289+
assert published_event['type'] == 'uk.nhs.notify.digital.letters.mesh.inbox.message.invalid.v1'
290+
assert published_event['source'] == '/nhs/england/notify/development/primary/data-plane/digitalletters/mesh'
291+
assert published_event['subject'] == 'customer/00000000-0000-0000-0000-000000000000/recipient/00000000-0000-0000-0000-000000000000'
292+
assert published_event['time'] == '2025-11-19T15:30:45+00:00'
293+
assert 'id' in published_event
294+
assert 'tracestate' not in published_event
295+
assert 'partitionkey' not in published_event
296+
assert 'sequence' not in published_event
297+
assert 'dataclassification' not in published_event
298+
assert 'dataregulation' not in published_event
299+
assert 'datacategory' not in published_event
300+
301+
# Verify CloudEvent data payload
302+
event_data = published_event['data']
303+
assert event_data['senderId'] == 'TEST-SENDER'
304+
assert event_data['messageReference'] == 'ref-001'
305+
assert event_data['meshMessageId'] == 'test-message-123'
306+
assert event_data['failureCode'] == 'DL_CLIV_005'
307+
assert set(event_data.keys()) == {'senderId', 'messageReference', 'meshMessageId', 'failureCode'}
308+
186309
def test_process_sqs_message_validation_failure(self):
187310
"""Malformed CloudEvents should be rejected by pydantic and not trigger downloads"""
188311
from mesh_download.processor import MeshDownloadProcessor

lambdas/mesh-download/mesh_download/processor.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from uuid import uuid4
44

55
from pydantic import ValidationError
6-
from digital_letters_events import MESHInboxMessageDownloaded, MESHInboxMessageReceived
6+
from digital_letters_events import MESHInboxMessageDownloaded, MESHInboxMessageReceived, MESHInboxMessageInvalid
77
from mesh_download.errors import MeshMessageNotFound
88
from mesh_download.document_store import DocumentAlreadyExistsError
9+
from nhs_notify_letters_onboarding import validate
910

1011

1112
class MeshDownloadProcessor:
@@ -54,6 +55,10 @@ def _parse_and_validate_event(self, sqs_record):
5455
)
5556
raise
5657

58+
def _validate_fhir_content(self, content):
59+
json_content = json.loads(content)
60+
validate(json_content)
61+
5762
def _handle_download(self, event, logger):
5863
data = event.data
5964

@@ -74,6 +79,18 @@ def _handle_download(self, event, logger):
7479
content = message.read()
7580
logger.info("Downloaded MESH message content")
7681

82+
try:
83+
self._validate_fhir_content(content)
84+
except Exception as e:
85+
logger.error("FHIR content is invalid", error=str(e))
86+
87+
self._publish_message_invalid_event(incoming_event=event)
88+
89+
message.acknowledge()
90+
logger.info("Acknowledged message")
91+
92+
return
93+
7794
duplicate = False
7895
try:
7996
uri = self._store_message_content(
@@ -155,3 +172,39 @@ def _publish_downloaded_event(self, incoming_event, message_uri):
155172
message_uri=message_uri,
156173
message_reference=incoming_event.data.messageReference
157174
)
175+
176+
def _publish_message_invalid_event(self, incoming_event):
177+
"""
178+
Publishes a MESHInboxMessageInvalid event.
179+
"""
180+
now = datetime.now(timezone.utc).isoformat()
181+
182+
cloud_event = {
183+
**incoming_event.model_dump(exclude_none=True),
184+
'id': str(uuid4()),
185+
'time': now,
186+
'recordedtime': now,
187+
'type': 'uk.nhs.notify.digital.letters.mesh.inbox.message.invalid.v1',
188+
'dataschema': (
189+
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/'
190+
'digital-letters-mesh-inbox-message-invalid-data.schema.json'
191+
),
192+
'data': {
193+
'senderId': incoming_event.data.senderId,
194+
'meshMessageId': incoming_event.data.meshMessageId,
195+
'failureCode': 'DL_CLIV_005',
196+
'messageReference': incoming_event.data.messageReference,
197+
}
198+
}
199+
200+
failed = self.__event_publisher.send_events([cloud_event], MESHInboxMessageInvalid)
201+
if failed:
202+
msg = f"Failed to publish MESHInboxMessageInvalid event: {failed}"
203+
self.__log.error(msg, failed_count=len(failed))
204+
raise RuntimeError(msg)
205+
206+
self.__log.info(
207+
"Published MESHInboxMessageInvalid event",
208+
sender_id=incoming_event.data.senderId,
209+
message_reference=incoming_event.data.messageReference
210+
)

lambdas/mesh-download/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ urllib3>=1.26.19,<2.0.0
88
idna>=3.7
99
requests>=2.32.0
1010
pyopenssl>=24.2.1
11+
nhs-notify-digital-letters-onboarding @ git+https://github.com/NHSDigital/nhs-notify-digital-letters-onboarding@0.1.0
1112
-e ../../src/digital-letters-events
1213
-e ../../utils/py-mock-mesh
1314
-e ../../utils/py-utils

src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-invalid-data.schema.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ properties:
1010
$ref: ../defs/requests.schema.yaml#/properties/senderId
1111
failureCode:
1212
$ref: ../defs/requests.schema.yaml#/properties/failureCode
13+
messageReference:
14+
$ref: ../defs/requests.schema.yaml#/properties/messageReference
1315
required:
1416
- meshMessageId
1517
- senderId

tests/playwright/constants/backend-constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ export const REPORTING_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REG
6161
export const PREFIX_DL_FILES = `${CSI}/`;
6262

6363
// Cloudwatch
64-
export const MESH_DOWNLOAD_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-mesh-download`;
6564
export const PDM_UPLOADER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-uploader`;
6665
export const PDM_POLL_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-poll`;
6766
export const CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-core-notifier`;
@@ -70,6 +69,7 @@ export const PRINT_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pr
7069
export const PRINT_ANALYSER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-print-analyser`;
7170
export const PRINT_SENDER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-print-sender`;
7271
export const MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-move-scanned-files`;
72+
export const MESH_DOWNLOAD_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-mesh-download`;
7373

7474
// Data Firehose
7575
export const FIREHOSE_STREAM_NAME = `${CSI}-to-s3-reporting`;

0 commit comments

Comments
 (0)