Skip to content

Commit 3b7a835

Browse files
authored
CCM-14319: Add localid validation to mesh poll (#210)
* CCM-14319: Add localid validation to mesh poll * CCM-14319: Fix linting issue * CCM-14319: Remove unused ValidationError
1 parent 7d146af commit 3b7a835

5 files changed

Lines changed: 234 additions & 13 deletions

File tree

lambdas/mesh-poll/mesh_poll/__tests__/test_handler.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""
22
Tests for Lambda handler
33
"""
4-
import pytest
54
from unittest.mock import Mock, patch, MagicMock
65

6+
import pytest
7+
78

89
def setup_mocks():
910
"""
@@ -39,7 +40,13 @@ class TestHandler:
3940
@patch('mesh_poll.handler.SenderLookup')
4041
@patch('mesh_poll.handler.MeshMessageProcessor')
4142
@patch('mesh_poll.handler.client')
42-
def test_handler_success(self, mock_boto_client, mock_processor_class, mock_sender_lookup_class, mock_config_class):
43+
def test_handler_success(
44+
self,
45+
mock_boto_client,
46+
mock_processor_class,
47+
mock_sender_lookup_class,
48+
mock_config_class
49+
):
4350
"""Test successful handler execution"""
4451
from mesh_poll.handler import handler
4552

@@ -75,7 +82,10 @@ def test_handler_success(self, mock_boto_client, mock_processor_class, mock_send
7582
assert call_kwargs['config'] == mock_config
7683
assert call_kwargs['sender_lookup'] == mock_sender_lookup
7784
assert call_kwargs['mesh_client'] == mock_config.mesh_client
78-
assert call_kwargs['get_remaining_time_in_millis'] == mock_context.get_remaining_time_in_millis
85+
assert (
86+
call_kwargs['get_remaining_time_in_millis']
87+
== mock_context.get_remaining_time_in_millis
88+
)
7989
assert call_kwargs['polling_metric'] == mock_config.polling_metric
8090
assert 'log' in call_kwargs
8191

@@ -86,7 +96,13 @@ def test_handler_success(self, mock_boto_client, mock_processor_class, mock_send
8696
@patch('mesh_poll.handler.SenderLookup')
8797
@patch('mesh_poll.handler.MeshMessageProcessor')
8898
@patch('mesh_poll.handler.client')
89-
def test_handler_config_cleanup_on_exception(self, mock_boto_client, mock_processor_class, mock_sender_lookup_class, mock_config_class):
99+
def test_handler_config_cleanup_on_exception(
100+
self,
101+
mock_boto_client,
102+
mock_processor_class,
103+
mock_sender_lookup_class,
104+
mock_config_class
105+
):
90106
"""Test that Config context manager cleanup is called even on exception"""
91107
from mesh_poll.handler import handler
92108

lambdas/mesh-poll/mesh_poll/__tests__/test_processor.py

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
Tests for mesh-poll MeshMessageProcessor
33
Following the pattern from backend comms-mgr mesh-poll tests
44
"""
5-
from unittest.mock import Mock, call, patch
5+
from unittest.mock import Mock, patch
6+
67
from mesh_client import MeshClient
78
from mesh_poll.processor import MeshMessageProcessor
89

@@ -166,10 +167,13 @@ def test_process_message_with_unknown_sender(self, mock_event_publisher_class):
166167

167168
sender_lookup.is_valid_sender.assert_called_once_with(message.sender)
168169
message.acknowledge.assert_called_once()
169-
mock_event_publisher.send_events.assert_not_called() # No event published for invalid sender
170+
mock_event_publisher.send_events.assert_not_called()
170171

171172
def test_process_message_logs_error_on_event_publish_failure(self, mock_event_publisher_class):
172-
"""Test that processor logs error when event publishing fails and does not acknowledge message"""
173+
"""
174+
Test that processor logs error when event publishing fails
175+
and does not acknowledge message
176+
"""
173177
(config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks()
174178
message = setup_message_data("1")
175179

@@ -224,5 +228,89 @@ def test_all_messages_are_processed_in_a_single_iteration(self, mock_event_publi
224228
mesh_client.handshake.assert_called_once()
225229
assert mesh_client.iterate_all_messages.call_count == 1
226230
assert sender_lookup.is_valid_sender.call_count == 3
227-
assert mock_event_publisher.send_events.call_count == 3 # Events published for all 3 messages
231+
assert mock_event_publisher.send_events.call_count == 3
228232
polling_metric.record.assert_called_once()
233+
234+
def test_process_message_rejects_missing_local_id(self, mock_event_publisher_class):
235+
"""Test that processor publishes MESHInboxMessageInvalid event for missing local_id"""
236+
(config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks()
237+
message = setup_message_data("1")
238+
message.local_id = None
239+
240+
mock_event_publisher = Mock()
241+
mock_event_publisher.send_events.return_value = []
242+
mock_event_publisher_class.return_value = mock_event_publisher
243+
244+
sender_lookup.is_valid_sender.return_value = True
245+
sender_lookup.get_sender_id.return_value = "test-sender-id"
246+
247+
processor = MeshMessageProcessor(
248+
config=config,
249+
sender_lookup=sender_lookup,
250+
mesh_client=mesh_client,
251+
get_remaining_time_in_millis=get_remaining_time_in_millis,
252+
log=log,
253+
polling_metric=polling_metric
254+
)
255+
256+
processor.process_message(message)
257+
258+
message.acknowledge.assert_called_once()
259+
mock_event_publisher.send_events.assert_called_once()
260+
261+
def test_process_message_rejects_empty_local_id(self, mock_event_publisher_class):
262+
"""Test that processor publishes MESHInboxMessageInvalid event for empty local_id"""
263+
(config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks()
264+
message = setup_message_data("1")
265+
message.local_id = ""
266+
267+
mock_event_publisher = Mock()
268+
mock_event_publisher.send_events.return_value = [] # Success
269+
mock_event_publisher_class.return_value = mock_event_publisher
270+
271+
sender_lookup.is_valid_sender.return_value = True
272+
sender_lookup.get_sender_id.return_value = "test-sender-id"
273+
274+
processor = MeshMessageProcessor(
275+
config=config,
276+
sender_lookup=sender_lookup,
277+
mesh_client=mesh_client,
278+
get_remaining_time_in_millis=get_remaining_time_in_millis,
279+
log=log,
280+
polling_metric=polling_metric
281+
)
282+
283+
processor.process_message(message)
284+
285+
message.acknowledge.assert_called_once()
286+
mock_event_publisher.send_events.assert_called_once()
287+
288+
def test_process_message_rejects_whitespace_only_local_id(self, mock_event_publisher_class):
289+
"""
290+
Test that processor publishes MESHInboxMessageInvalid event
291+
for whitespace-only local_id
292+
"""
293+
(config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks()
294+
message = setup_message_data("1")
295+
message.local_id = " "
296+
297+
mock_event_publisher = Mock()
298+
mock_event_publisher.send_events.return_value = [] # Success
299+
mock_event_publisher_class.return_value = mock_event_publisher
300+
301+
sender_lookup.is_valid_sender.return_value = True
302+
sender_lookup.get_sender_id.return_value = "test-sender-id"
303+
304+
processor = MeshMessageProcessor(
305+
config=config,
306+
sender_lookup=sender_lookup,
307+
mesh_client=mesh_client,
308+
get_remaining_time_in_millis=get_remaining_time_in_millis,
309+
log=log,
310+
polling_metric=polling_metric
311+
)
312+
313+
processor.process_message(message)
314+
315+
message.acknowledge.assert_called_once()
316+
mock_event_publisher.send_events.assert_called_once()

lambdas/mesh-poll/mesh_poll/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44
from dl_utils import BaseMeshConfig, Metric, log
55

6+
__all__ = ['Config', 'log']
67

78
_REQUIRED_ENV_VAR_MAP = {
89
"ssm_senders_prefix": "SSM_SENDERS_PREFIX",

lambdas/mesh-poll/mesh_poll/processor.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from uuid import uuid4
77

88
from dl_utils import EventPublisher
9-
from digital_letters_events import MESHInboxMessageReceived
9+
from digital_letters_events import MESHInboxMessageReceived, MESHInboxMessageInvalid
1010

1111
from .errors import AuthorizationError, format_exception
1212

@@ -31,7 +31,10 @@ def __init__(self, **kwargs):
3131
environment = 'development'
3232
deployment = 'primary'
3333
plane = 'data-plane'
34-
self.__cloud_event_source = f'/nhs/england/notify/{environment}/{deployment}/{plane}/digitalletters/mesh'
34+
self.__cloud_event_source = (
35+
f'/nhs/england/notify/{environment}/{deployment}/{plane}/'
36+
'digitalletters/mesh'
37+
)
3538

3639
# Initialize EventPublisher
3740
self.__event_publisher = EventPublisher(
@@ -97,14 +100,33 @@ def process_message(self, message):
97100
logger.info(PROCESSING_MESSAGE)
98101

99102
try:
100-
# Basic sender validation - only publish events for known senders
103+
# Basic sender validation - only process messages from known senders
101104
if not self.__sender_lookup.is_valid_sender(sender_mailbox_id):
102105
raise AuthorizationError(
103106
f'Cannot authorize sender with mailbox ID "{sender_mailbox_id}"')
104107

105108
# Get the corresponding sender ID
106109
sender_id = self.__sender_lookup.get_sender_id(sender_mailbox_id)
107110

111+
if not message_reference or message_reference.strip() == "":
112+
logger.error('Message has missing or empty local_id')
113+
114+
# Publish MESHInboxMessageInvalid event
115+
message_id = message.id()
116+
event_detail = {
117+
"data": {
118+
"meshMessageId": message_id,
119+
"senderId": sender_id,
120+
"failureCode": "LID_MESH_0001"
121+
}
122+
}
123+
124+
self._publish_mesh_inbox_message_invalid_event(event_detail)
125+
126+
message.acknowledge() # Remove from inbox
127+
logger.info(ACKNOWLEDGED_MESSAGE)
128+
return
129+
108130
# Publish event for valid sender
109131
message_id = message.id()
110132
event_detail = {
@@ -136,14 +158,20 @@ def _publish_mesh_inbox_message_received_event(self, event_detail):
136158
'id': str(uuid4()),
137159
'specversion': '1.0',
138160
'source': self.__cloud_event_source,
139-
'subject': f'customer/{event_detail["data"]["senderId"]}/recipient/{event_detail["data"]["messageReference"]}',
161+
'subject': (
162+
f'customer/{event_detail["data"]["senderId"]}/recipient/'
163+
f'{event_detail["data"]["messageReference"]}'
164+
),
140165
'type': 'uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1',
141166
'time': now,
142167
'recordedtime': now,
143168
'severitynumber': 2,
144169
'severitytext': 'INFO',
145170
'traceparent': '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
146-
'dataschema': 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-received-data.schema.json',
171+
'dataschema': (
172+
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/'
173+
'2025-10-draft/data/digital-letters-mesh-inbox-message-received-data.schema.json'
174+
),
147175
'datacontenttype': 'application/json',
148176
'data': event_detail.get('data', {}),
149177
}
@@ -158,3 +186,40 @@ def _publish_mesh_inbox_message_received_event(self, event_detail):
158186
self.__log.info("Published MESHInboxMessageReceived event",
159187
mesh_message_id=event_detail["data"]["meshMessageId"],
160188
sender_id=event_detail["data"]["senderId"])
189+
190+
def _publish_mesh_inbox_message_invalid_event(self, event_detail):
191+
"""
192+
Publishes a MESHInboxMessageInvalid event when a message fails validation.
193+
"""
194+
now = datetime.now(timezone.utc).isoformat()
195+
196+
cloud_event = {
197+
'id': str(uuid4()),
198+
'specversion': '1.0',
199+
'source': self.__cloud_event_source,
200+
'subject': f'customer/{event_detail["data"]["senderId"]}',
201+
'type': 'uk.nhs.notify.digital.letters.mesh.inbox.message.invalid.v1',
202+
'time': now,
203+
'recordedtime': now,
204+
'severitynumber': 3,
205+
'severitytext': 'WARN',
206+
'traceparent': '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
207+
'dataschema': (
208+
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/'
209+
'2025-10-draft/data/digital-letters-mesh-inbox-message-invalid-data.schema.json'
210+
),
211+
'datacontenttype': 'application/json',
212+
'data': event_detail.get('data', {})
213+
}
214+
215+
failed_events = self.__event_publisher.send_events([cloud_event], MESHInboxMessageInvalid)
216+
217+
if failed_events:
218+
error_msg = f"Failed to publish MESHInboxMessageInvalid event: {failed_events}"
219+
self.__log.error(error_msg, failed_count=len(failed_events))
220+
raise RuntimeError(error_msg)
221+
222+
self.__log.info("Published MESHInboxMessageInvalid event",
223+
mesh_message_id=event_detail["data"]["meshMessageId"],
224+
sender_id=event_detail["data"]["senderId"],
225+
failure_code=event_detail["data"]["failureCode"])

tests/playwright/digital-letters-component-tests/mesh-poll-download.component.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,55 @@ test.describe('Digital Letters - MESH Poll and Download', () => {
179179
await expectMeshInboxMessageReceivedEvent(msg.meshMessageId);
180180
}
181181
});
182+
183+
test('should publish MESHInboxMessageInvalid event when local_id is missing', async () => {
184+
const meshMessageId = `${Date.now()}_INVALID_${uuidv4().slice(0, 8)}`;
185+
const messageContent = JSON.stringify({
186+
senderId,
187+
testData: 'This message has no local_id',
188+
timestamp: new Date().toISOString(),
189+
});
190+
191+
await uploadMeshMessage(meshMessageId, '', messageContent, {
192+
local_id: '',
193+
});
194+
195+
await invokeLambda(MESH_POLL_LAMBDA_NAME);
196+
197+
await expectToPassEventually(async () => {
198+
const eventLogEntry = await getLogsFromCloudwatch(
199+
`/aws/vendedlogs/events/event-bus/nhs-${ENV}-dl`,
200+
[
201+
'$.message_type = "EVENT_RECEIPT"',
202+
'$.details.detail_type = "uk.nhs.notify.digital.letters.mesh.inbox.message.invalid.v1"',
203+
String.raw`$.details.event_detail = "*\"meshMessageId\":\"${meshMessageId}\"*"`,
204+
String.raw`$.details.event_detail = "*\"senderId\":\"${senderId}\"*"`,
205+
String.raw`$.details.event_detail = "*\"failureCode\":\"LID_MESH_0001\"*"`,
206+
],
207+
);
208+
209+
expect(eventLogEntry.length).toBeGreaterThanOrEqual(1);
210+
}, 120_000);
211+
212+
await expectToPassEventually(async () => {
213+
await expect(async () => {
214+
await downloadFromS3(
215+
NON_PII_S3_BUCKET_NAME,
216+
`mock-mesh/${meshMailboxId}/in/${meshMessageId}`,
217+
);
218+
}).rejects.toThrow('No objects found');
219+
}, 60_000);
220+
221+
await expectToPassEventually(async () => {
222+
const receivedEvents = await getLogsFromCloudwatch(
223+
`/aws/vendedlogs/events/event-bus/nhs-${ENV}-dl`,
224+
[
225+
'$.message_type = "EVENT_RECEIPT"',
226+
'$.details.detail_type = "uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1"',
227+
`$.details.event_detail = "*\\"meshMessageId\\":\\"${meshMessageId}\\"*"`,
228+
],
229+
);
230+
expect(receivedEvents.length).toBe(0);
231+
}, 15_000);
232+
});
182233
});

0 commit comments

Comments
 (0)