Skip to content

Commit 737b8ce

Browse files
authored
Merge pull request #194 from NHSDigital/release/2024-05-15
Release/2024-05-15
2 parents 65d1099 + 71cfff4 commit 737b8ce

File tree

28 files changed

+848
-137
lines changed

28 files changed

+848
-137
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 2024-05-15
4+
- [PI-336] Changelog deletes
5+
- Dependabot (pydantic)
6+
37
## 2024-05-02
48
- [PI-341] Prod permissions
59
- [PI-268] Search for a device

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2024.05.02
1+
2024.05.15

changelog/2024-05-15.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- [PI-336] Changelog deletes
2+
- Dependabot (pydantic)

infrastructure/terraform/per_workspace/modules/etl/sds/main.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ module "worker_transform" {
116116
"dynamodb:Query"
117117
],
118118
"Effect": "Allow",
119-
"Resource": ["${var.table_arn}"]
119+
"Resource": ["${var.table_arn}", "${var.table_arn}/*"]
120120
},
121121
{
122122
"Action": [

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "connecting-party-manager"
3-
version = "2024.05.02"
3+
version = "2024.05.15"
44
description = "Repository for the Connecting Party Manager API and related services"
55
authors = ["NHS England"]
66
license = "LICENSE.md"

src/etl/sds/tests/test_sds_etl_components.py

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import boto3
66
import pytest
7-
from domain.core.device import DeviceType
7+
from domain.core.device import DeviceStatus, DeviceType
88
from domain.core.device_key import DeviceKeyType
99
from etl.clear_state_inputs import EMPTY_JSON_DATA, EMPTY_LDIF_DATA
1010
from etl_utils.constants import CHANGELOG_NUMBER, WorkerKey
@@ -24,6 +24,21 @@
2424
from test_helpers.pytest_skips import long_running
2525
from test_helpers.terraform import read_terraform_output
2626

27+
# Note that unique identifier "000428682512" is the identifier of 'GOOD_SDS_RECORD'
28+
DELETION_REQUEST_000428682512 = """
29+
dn: o=nhs,ou=Services,uniqueIdentifier=000428682512
30+
changetype: delete
31+
objectclass: delete
32+
uniqueidentifier: 000428682512
33+
"""
34+
35+
DELETION_REQUEST_000842065542 = """
36+
dn: o=nhs,ou=Services,uniqueIdentifier=000842065542
37+
changetype: delete
38+
objectclass: delete
39+
uniqueidentifier: 000842065542
40+
"""
41+
2742

2843
@pytest.fixture
2944
def state_machine_input(request: pytest.FixtureRequest):
@@ -74,7 +89,7 @@ def execute_state_machine(
7489
error_message = cause["errorMessage"]
7590
stack_trace = cause["stackTrace"]
7691
except Exception:
77-
error_message = response["cause"]
92+
error_message = response.get("cause", "no error message")
7893
stack_trace = []
7994

8095
print( # noqa: T201
@@ -83,7 +98,7 @@ def execute_state_machine(
8398
"\n",
8499
*stack_trace,
85100
)
86-
raise RuntimeError(response["error"])
101+
raise RuntimeError(response.get("error", "no error message"))
87102
return response
88103

89104

@@ -102,6 +117,12 @@ def get_object(key: WorkerKey) -> str:
102117
return response["Body"].read()
103118

104119

120+
def put_object(key: WorkerKey, body: bytes) -> str:
121+
client = boto3.client("s3")
122+
etl_bucket = read_terraform_output("sds_etl.value.bucket")
123+
return client.put_object(Bucket=etl_bucket, Key=key, Body=body)
124+
125+
105126
@pytest.mark.integration
106127
@pytest.mark.parametrize(
107128
"worker_data",
@@ -206,3 +227,88 @@ def test_end_to_end_bulk_trigger(repository: MockDeviceRepository):
206227

207228
assert product_count == accredited_system_count == 5670
208229
assert endpoint_count == message_handling_system_count == 154506
230+
231+
232+
@pytest.mark.integration
233+
@pytest.mark.parametrize(
234+
"worker_data",
235+
[
236+
{
237+
WorkerKey.EXTRACT: "\n".join([GOOD_SDS_RECORD, ANOTHER_GOOD_SDS_RECORD]),
238+
WorkerKey.TRANSFORM: pkl_dumps_lz4(deque()),
239+
WorkerKey.LOAD: pkl_dumps_lz4(deque()),
240+
}
241+
],
242+
indirect=True,
243+
)
244+
@pytest.mark.parametrize(
245+
"state_machine_input",
246+
[
247+
StateMachineInput.bulk(changelog_number=123),
248+
],
249+
indirect=True,
250+
)
251+
def test_end_to_end_changelog_delete(
252+
repository: MockDeviceRepository, worker_data, state_machine_input
253+
):
254+
"""Note that the start of this test is the same as test_end_to_end, and then makes changes"""
255+
extract_data = get_object(key=WorkerKey.EXTRACT)
256+
transform_data = pkl_loads_lz4(get_object(key=WorkerKey.TRANSFORM))
257+
load_data = pkl_loads_lz4(get_object(key=WorkerKey.LOAD))
258+
259+
assert len(extract_data) == 0
260+
assert len(transform_data) == 0
261+
assert len(load_data) == 0
262+
assert len(list(repository.all_devices())) == len(
263+
worker_data[WorkerKey.EXTRACT].split("\n\n")
264+
)
265+
266+
# Now execute a changelog initial state in the ETL
267+
put_object(key=WorkerKey.EXTRACT, body=DELETION_REQUEST_000428682512)
268+
response = execute_state_machine(
269+
state_machine_input=StateMachineInput.update(
270+
changelog_number_start=124, changelog_number_end=125
271+
)
272+
)
273+
assert response["status"] == "SUCCEEDED"
274+
275+
# Verify that the device with unique id 000428682512 is now "inactive"
276+
(device,) = repository.read_by_index(
277+
questionnaire_id="spine_device/1",
278+
question_name="unique_identifier",
279+
value="000428682512",
280+
)
281+
assert device.status == DeviceStatus.INACTIVE
282+
283+
# Verify that the other device is still "active"
284+
(device,) = repository.read_by_index(
285+
questionnaire_id="spine_device/1",
286+
question_name="unique_identifier",
287+
value="000842065542",
288+
)
289+
assert device.status == DeviceStatus.ACTIVE
290+
291+
# Execute another changelog initial state in the ETL
292+
put_object(key=WorkerKey.EXTRACT, body=DELETION_REQUEST_000842065542)
293+
response = execute_state_machine(
294+
state_machine_input=StateMachineInput.update(
295+
changelog_number_start=124, changelog_number_end=125
296+
)
297+
)
298+
assert response["status"] == "SUCCEEDED"
299+
300+
# Verify that the device with unique id 000428682512 is still "inactive"
301+
(device,) = repository.read_by_index(
302+
questionnaire_id="spine_device/1",
303+
question_name="unique_identifier",
304+
value="000428682512",
305+
)
306+
assert device.status == DeviceStatus.INACTIVE
307+
308+
# Verify that the other device is now "inactive"
309+
(device,) = repository.read_by_index(
310+
questionnaire_id="spine_device/1",
311+
question_name="unique_identifier",
312+
value="000842065542",
313+
)
314+
assert device.status == DeviceStatus.INACTIVE

src/etl/sds/trigger/update/operations.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,9 @@ def get_latest_changelog_number_from_ldap(
9797
],
9898
)
9999

100-
_, (unpack_record) = record
101-
102-
return int(
103-
unpack_record[ChangelogAttributes.LAST_CHANGELOG_NUMBER][0].decode("utf-8")
104-
)
100+
_, (_record) = record
101+
(last_changelog_number_str,) = _record[ChangelogAttributes.LAST_CHANGELOG_NUMBER]
102+
return int(last_changelog_number_str)
105103

106104

107105
def get_changelog_entries_from_ldap(

src/etl/sds/trigger/update/tests/test_update_trigger.py

Lines changed: 110 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from unittest import mock
44

55
import boto3
6+
import pytest
67
from etl_utils.constants import CHANGELOG_NUMBER
78
from moto import mock_aws
89

@@ -16,39 +17,96 @@
1617
"CPM_FQDN": "cpm-fqdn",
1718
"LDAP_HOST": "ldap-host",
1819
"ETL_BUCKET": "etl-bucket",
19-
"ETL_EXTRACT_INPUT_KEY": "etl-input",
2020
"LDAP_CHANGELOG_USER": "user",
2121
"LDAP_CHANGELOG_PASSWORD": "eggs", # pragma: allowlist secret
2222
}
2323

2424
ALLOWED_EXCEPTIONS = (JSONDecodeError,)
25-
CHANGELOG_NUMBER_VALUE = "538684"
25+
LATEST_CHANGELOG_NUMBER = b"540382"
26+
CURRENT_CHANGELOG_NUMBER = str(int(LATEST_CHANGELOG_NUMBER) - 1).encode()
2627

28+
CHANGELOG_NUMBER_RESULT = [
29+
101,
30+
[
31+
[
32+
"cn=changelog,o=nhs",
33+
{
34+
"firstchangenumber": [b"46425"],
35+
"lastchangenumber": [LATEST_CHANGELOG_NUMBER],
36+
},
37+
]
38+
],
39+
]
40+
41+
CHANGE_RESULT = (
42+
101,
43+
[
44+
[
45+
"changenumber=540246,cn=changelog,o=nhs",
46+
{
47+
"objectClass": [
48+
b"top",
49+
b"changeLogEntry",
50+
b"nhsExternalChangelogEntry",
51+
],
52+
"changeNumber": [b"540246"],
53+
"changes": [
54+
b"\\nobjectClass: nhsmhsservice\\nobjectClass: top\\nnhsIDCode: F2R5Q\\nnhsMHSPartyKey: F2R5Q-823886\\nnhsMHSServiceName: urn:nhs:names:services:pdsquery\\nuniqueIdentifier: 4d554a907e83a4067695"
55+
],
56+
"changeTime": [b"20240502100040Z"],
57+
"changeType": [b"add"],
58+
"targetDN": [
59+
b"uniqueIdentifier=4d554a907e83a4067695,ou=Services,o=nhs"
60+
],
61+
},
62+
]
63+
],
64+
)
65+
66+
CHANGE_RESULT_WITHOUT_UNIQUE_IDENTIFIER = (
67+
101,
68+
[
69+
[
70+
"changenumber=540246,cn=changelog,o=nhs",
71+
{
72+
"objectClass": [
73+
b"top",
74+
b"changeLogEntry",
75+
b"nhsExternalChangelogEntry",
76+
],
77+
"changeNumber": [b"540246"],
78+
"changes": [
79+
b"\\nobjectClass: nhsmhsservice\\nobjectClass: top\\nnhsIDCode: F2R5Q\\nnhsMHSPartyKey: F2R5Q-823886\\nnhsMHSServiceName: urn:nhs:names:services:pdsquery\\n"
80+
],
81+
"changeTime": [b"20240502100040Z"],
82+
"changeType": [b"add"],
83+
"targetDN": [
84+
b"uniqueIdentifier=4d554a907e83a4067695,ou=Services,o=nhs"
85+
],
86+
},
87+
]
88+
],
89+
)
2790

28-
def test_update():
91+
92+
CHANGE_AS_LDIF = """dn: o=nhs,ou=services,uniqueidentifier=4d554a907e83a4067695
93+
changetype: add
94+
objectClass: nhsmhsservice
95+
objectClass: top
96+
nhsIDCode: F2R5Q
97+
nhsMHSPartyKey: F2R5Q-823886
98+
nhsMHSServiceName: urn:nhs:names:services:pdsquery
99+
uniqueIdentifier: 4d554a907e83a4067695""".encode()
100+
101+
102+
@pytest.mark.parametrize(
103+
"change_result", [CHANGE_RESULT, CHANGE_RESULT_WITHOUT_UNIQUE_IDENTIFIER]
104+
)
105+
def test_update(change_result):
29106
mocked_ldap = mock.Mock()
30107
mocked_ldap_client = mock.Mock()
31108
mocked_ldap.initialize.return_value = mocked_ldap_client
32-
mocked_ldap_client.result.return_value = (
33-
101,
34-
[
35-
(
36-
"changenumber=75852519,cn=changelog,o=nhs",
37-
{
38-
"objectclass": {
39-
"top",
40-
"changeLogEntry",
41-
"nhsExternalChangelogEntry",
42-
},
43-
"changenumber": "75852519",
44-
"changes": "foo",
45-
"changetime": "20240116173441Z",
46-
"changetype": "add",
47-
"targetdn": "uniqueIdentifier=200000042019,ou=Services,o=nhs",
48-
},
49-
),
50-
],
51-
)
109+
mocked_ldap_client.result.side_effect = (CHANGELOG_NUMBER_RESULT, change_result)
52110

53111
with mock_aws(), mock.patch.dict(
54112
os.environ, MOCKED_UPDATE_TRIGGER_ENVIRONMENT, clear=True
@@ -75,7 +133,7 @@ def test_update():
75133
s3_client.put_object(
76134
Bucket=MOCKED_UPDATE_TRIGGER_ENVIRONMENT["ETL_BUCKET"],
77135
Key=CHANGELOG_NUMBER,
78-
Body="0",
136+
Body=CURRENT_CHANGELOG_NUMBER,
79137
)
80138

81139
from etl.sds.trigger.update import update
@@ -86,12 +144,36 @@ def test_update():
86144
update.CACHE["ldap_client"] = mocked_ldap_client
87145

88146
# Remove start execution, since it's meaningless
89-
idx = update.steps.index(_start_execution)
90-
update.steps.pop(idx)
147+
if _start_execution in update.steps:
148+
idx = update.steps.index(_start_execution)
149+
update.steps.pop(idx)
91150

92151
# Don't execute the notify lambda
93-
update.notify = mock.Mock(return_value="abc")
152+
update.notify = (
153+
lambda lambda_client, function_name, result, trigger_type: result
154+
)
94155

156+
# Execute the test
95157
response = update.handler()
96158

97-
assert response == "abc"
159+
# Verify the changelog number is NOT updated (as it should be updated in the ETL, not the trigger)
160+
changelog_number_response = s3_client.get_object(
161+
Bucket=MOCKED_UPDATE_TRIGGER_ENVIRONMENT["ETL_BUCKET"], Key=CHANGELOG_NUMBER
162+
)
163+
assert changelog_number_response["Body"].read() == CURRENT_CHANGELOG_NUMBER
164+
165+
# Verify the history file was created
166+
etl_history_response = s3_client.get_object(
167+
Bucket=MOCKED_UPDATE_TRIGGER_ENVIRONMENT["ETL_BUCKET"],
168+
Key=f"history/changelog/{int(LATEST_CHANGELOG_NUMBER)}/input--extract/unprocessed",
169+
)
170+
assert etl_history_response["Body"].read().lower() == CHANGE_AS_LDIF.lower()
171+
172+
# Verify the ETL input file was created
173+
etl_input_response = s3_client.get_object(
174+
Bucket=MOCKED_UPDATE_TRIGGER_ENVIRONMENT["ETL_BUCKET"],
175+
Key="input--extract/unprocessed",
176+
)
177+
assert etl_input_response["Body"].read().lower() == CHANGE_AS_LDIF.lower()
178+
179+
assert not isinstance(response, Exception), response

0 commit comments

Comments
 (0)