Skip to content

Commit e5d2941

Browse files
committed
CCM-14566: Add FHIR validation to mesh-download
1 parent 12012c7 commit e5d2941

6 files changed

Lines changed: 183 additions & 6 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: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,58 @@ def create_sqs_record(cloud_event=None):
6868
'body': json.dumps({'detail': cloud_event})
6969
}
7070

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

72124
def create_mesh_message(message_id='test_123', sender='SENDER_001', local_id='ref_001'):
73125
"""
@@ -80,8 +132,9 @@ def create_mesh_message(message_id='test_123', sender='SENDER_001', local_id='re
80132
message.subject = 'test_document.pdf'
81133
message.workflow_id = 'TEST_WORKFLOW'
82134
message.message_type = 'DATA'
83-
message.read.return_value = b'Test message content'
135+
message.read.return_value = create_fhir_content()
84136
message.acknowledge = Mock()
137+
85138
return message
86139

87140

@@ -142,7 +195,7 @@ def test_process_sqs_message_success(self, mock_datetime):
142195
document_store.store_document.assert_called_once_with(
143196
sender_id='TEST_SENDER',
144197
message_reference='ref_001',
145-
content=b'Test message content'
198+
content=create_fhir_content()
146199
)
147200

148201
mesh_message.acknowledge.assert_called_once()
@@ -177,6 +230,75 @@ def test_process_sqs_message_success(self, mock_datetime):
177230
assert event_data['messageUri'] == 's3://test-pii-bucket/document-reference/SENDER_001_ref_001'
178231
assert set(event_data.keys()) == {'senderId', 'messageReference', 'messageUri', 'meshMessageId'}
179232

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

910

1011
class MeshDownloadProcessor:
@@ -52,6 +53,10 @@ def _parse_and_validate_event(self, sqs_record):
5253
)
5354
raise
5455

56+
def _validate_fhir_content(self, content):
57+
json_content = json.loads(content)
58+
validate(json_content)
59+
5560
def _handle_download(self, event, logger):
5661
data = event.data
5762

@@ -72,6 +77,18 @@ def _handle_download(self, event, logger):
7277
content = message.read()
7378
logger.info("Downloaded MESH message content")
7479

80+
try:
81+
self._validate_fhir_content(content)
82+
except Exception as e:
83+
logger.error("FHIR content is not invalid", error=str(e))
84+
85+
self._publish_message_invalid_event(incoming_event=event)
86+
87+
message.acknowledge()
88+
logger.info("Acknowledged message")
89+
90+
return
91+
7592
uri = self._store_message_content(
7693
sender_id=data.senderId,
7794
message_reference=data.messageReference,
@@ -139,3 +156,39 @@ def _publish_downloaded_event(self, incoming_event, message_uri):
139156
message_uri=message_uri,
140157
message_reference=incoming_event.data.messageReference
141158
)
159+
160+
def _publish_message_invalid_event(self, incoming_event):
161+
"""
162+
Publishes a MESHInboxMessageInvalid event.
163+
"""
164+
now = datetime.now(timezone.utc).isoformat()
165+
166+
cloud_event = {
167+
**incoming_event.model_dump(exclude_none=True),
168+
'id': str(uuid4()),
169+
'time': now,
170+
'recordedtime': now,
171+
'type': 'uk.nhs.notify.digital.letters.mesh.inbox.message.invalid.v1',
172+
'dataschema': (
173+
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/'
174+
'digital-letters-mesh-inbox-message-invalid-data.schema.json'
175+
),
176+
'data': {
177+
'senderId': incoming_event.data.senderId,
178+
'meshMessageId': incoming_event.data.meshMessageId,
179+
'failureCode': 'DL_CLIV_005',
180+
'messageReference': incoming_event.data.messageReference,
181+
}
182+
}
183+
184+
failed = self.__event_publisher.send_events([cloud_event], MESHInboxMessageInvalid)
185+
if failed:
186+
msg = f"Failed to publish MESHInboxMessageInvalid event: {failed}"
187+
self.__log.error(msg, failed_count=len(failed))
188+
raise RuntimeError(msg)
189+
190+
self.__log.info(
191+
"Published MESHInboxMessageInvalid event",
192+
sender_id=incoming_event.data.senderId,
193+
message_reference=incoming_event.data.messageReference
194+
)

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@75362ff36814a0b355f95ad8b6834e400c49c161
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

0 commit comments

Comments
 (0)