From 18b705ad4d99bb0f7e7dbc1d29b35b8f17de35cd Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:38:32 +0000 Subject: [PATCH 01/25] [CDAPI-148]: Added initial PDM mock client for integration tests --- pathology-api/tests/conftest.py | 33 +++++++++++++++++++ .../tests/integration/test_endpoints.py | 9 ++++- pathology-api/tests/mock_client.py | 32 ++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 pathology-api/tests/mock_client.py diff --git a/pathology-api/tests/conftest.py b/pathology-api/tests/conftest.py index 3fde7e72..b0a169ee 100644 --- a/pathology-api/tests/conftest.py +++ b/pathology-api/tests/conftest.py @@ -1,6 +1,8 @@ """Pytest configuration and shared fixtures for pathology API tests.""" import os +import tempfile +from collections.abc import Generator from datetime import timedelta from typing import Any, Literal, Protocol, cast @@ -8,6 +10,8 @@ import requests from dotenv import load_dotenv +from .mock_client import CertificateDetails, PDMMockClient + load_dotenv() type _RequestMethod = Literal["GET", "POST"] @@ -252,6 +256,35 @@ def client(request: pytest.FixtureRequest, base_url: str) -> Client: raise ValueError(f"Unknown env: {env}") +@pytest.fixture(scope="module") +def client_cert() -> Generator[CertificateDetails | None, None, None]: + client_cert = _fetch_env_variable("CLIENT_CERT", str) + client_key = _fetch_env_variable("CLIENT_KEY", str) + with ( + tempfile.NamedTemporaryFile(delete=True) as cert_file, + tempfile.NamedTemporaryFile(delete=True) as key_file, + ): + cert_file.write(client_cert.encode()) + cert_file.flush() + key_file.write(client_key.encode()) + key_file.flush() + yield { + "cert_path": cert_file.name, + "key_path": key_file.name, + } + + yield None + + +@pytest.fixture(scope="module") +def pdm_mock_client(client_cert: CertificateDetails | None) -> PDMMockClient: + return PDMMockClient( + url=_fetch_env_variable("PDM_MOCK_URL", str), + timeout=timedelta(seconds=5), + client_cert=client_cert, + ) + + def _create_remote_client(request: pytest.FixtureRequest) -> RemoteClient: """Create a RemoteClient with auth headers chosen by test markers. diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index bf51e95f..5e524e48 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -12,11 +12,15 @@ from pydantic import BaseModel, HttpUrl from tests.conftest import Client +from tests.mock_client import PDMMockClient class TestBundleEndpoint: def test_bundle_returns_200( - self, client: Client, build_valid_test_result: Callable[[str, str], Bundle] + self, + client: Client, + build_valid_test_result: Callable[[str, str], Bundle], + pdm_mock_client: PDMMockClient, ) -> None: bundle = build_valid_test_result("nhs_number", "ods_code") @@ -53,6 +57,9 @@ def test_bundle_returns_200( assert response.headers["etag"] == 'W/"1"' + sent_request = pdm_mock_client.retrieve_sent_request(response_bundle.id) + assert sent_request == bundle.model_dump_json(by_alias=True) + def test_no_payload_returns_error(self, client: Client) -> None: response = client.send_without_payload( request_method="POST", path="FHIR/R4/Bundle" diff --git a/pathology-api/tests/mock_client.py b/pathology-api/tests/mock_client.py new file mode 100644 index 00000000..c1668f22 --- /dev/null +++ b/pathology-api/tests/mock_client.py @@ -0,0 +1,32 @@ +from datetime import timedelta +from typing import Any, TypedDict + +import requests + + +class CertificateDetails(TypedDict): + cert_path: str + key_path: str + + +class PDMMockClient: + def __init__( + self, url: str, timeout: timedelta, client_cert: CertificateDetails | None + ): + self._url = url + self._timeout = timeout + self._client_cert = client_cert + + def retrieve_sent_request(self, request_id: str) -> Any: + certs = ( + (self._client_cert["cert_path"], self._client_cert["key_path"]) + if self._client_cert + else None + ) + + response = requests.get( + self._url + request_id, + timeout=self._timeout.total_seconds(), + cert=certs, + ) + return response.json() From b862f85cac00fe955a2ed61536b7fdf75d9b2394 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:13:38 +0000 Subject: [PATCH 02/25] [CDAPI-148]: Added initial MNS mock client for use by the integration tests --- pathology-api/tests/conftest.py | 57 +++++++++++++------ .../tests/integration/test_endpoints.py | 24 +++++++- pathology-api/tests/mock_client.py | 25 +++++++- 3 files changed, 87 insertions(+), 19 deletions(-) diff --git a/pathology-api/tests/conftest.py b/pathology-api/tests/conftest.py index b0a169ee..59de7b1f 100644 --- a/pathology-api/tests/conftest.py +++ b/pathology-api/tests/conftest.py @@ -10,7 +10,7 @@ import requests from dotenv import load_dotenv -from .mock_client import CertificateDetails, PDMMockClient +from .mock_client import CertificateDetails, MNSMockClient, PDMMockClient load_dotenv() @@ -260,26 +260,51 @@ def client(request: pytest.FixtureRequest, base_url: str) -> Client: def client_cert() -> Generator[CertificateDetails | None, None, None]: client_cert = _fetch_env_variable("CLIENT_CERT", str) client_key = _fetch_env_variable("CLIENT_KEY", str) - with ( - tempfile.NamedTemporaryFile(delete=True) as cert_file, - tempfile.NamedTemporaryFile(delete=True) as key_file, - ): - cert_file.write(client_cert.encode()) - cert_file.flush() - key_file.write(client_key.encode()) - key_file.flush() - yield { - "cert_path": cert_file.name, - "key_path": key_file.name, - } - yield None + if client_cert and client_key: + with ( + tempfile.NamedTemporaryFile(delete=True) as cert_file, + tempfile.NamedTemporaryFile(delete=True) as key_file, + ): + cert_file.write(client_cert.encode()) + cert_file.flush() + key_file.write(client_key.encode()) + key_file.flush() + yield { + "cert_path": cert_file.name, + "key_path": key_file.name, + } + else: + yield None + + +@pytest.fixture(scope="module") +def pdm_mock_url() -> str: + return _fetch_env_variable("PDM_MOCK_URL", str) + + +@pytest.fixture(scope="module") +def mns_mock_url() -> str: + return _fetch_env_variable("MNS_MOCK_URL", str) @pytest.fixture(scope="module") -def pdm_mock_client(client_cert: CertificateDetails | None) -> PDMMockClient: +def pdm_mock_client( + client_cert: CertificateDetails | None, pdm_mock_url: str +) -> PDMMockClient: return PDMMockClient( - url=_fetch_env_variable("PDM_MOCK_URL", str), + url=pdm_mock_url, + timeout=timedelta(seconds=5), + client_cert=client_cert, + ) + + +@pytest.fixture(scope="module") +def mns_mock_client( + client_cert: CertificateDetails | None, mns_mock_url: str +) -> MNSMockClient: + return MNSMockClient( + url=mns_mock_url, timeout=timedelta(seconds=5), client_cert=client_cert, ) diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index 5e524e48..c640922d 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -12,7 +12,7 @@ from pydantic import BaseModel, HttpUrl from tests.conftest import Client -from tests.mock_client import PDMMockClient +from tests.mock_client import MNSMockClient, PDMMockClient class TestBundleEndpoint: @@ -21,8 +21,12 @@ def test_bundle_returns_200( client: Client, build_valid_test_result: Callable[[str, str], Bundle], pdm_mock_client: PDMMockClient, + mns_mock_client: MNSMockClient, + pdm_mock_url: str, ) -> None: - bundle = build_valid_test_result("nhs_number", "ods_code") + subject = "nhs_number" + requesting_ods_code = "ods_code" + bundle = build_valid_test_result(subject, requesting_ods_code) response = client.send( data=bundle.model_dump_json(by_alias=True), @@ -60,6 +64,22 @@ def test_bundle_returns_200( sent_request = pdm_mock_client.retrieve_sent_request(response_bundle.id) assert sent_request == bundle.model_dump_json(by_alias=True) + published_events = mns_mock_client.retrieve_sent_messages(subject) + assert len(published_events) == 1 + + published_event = published_events[0] + assert published_event["subject"] == subject + assert published_event["dataref"] == pdm_mock_url + response_bundle.id + assert published_event["filtering"] == { + "requestingOrganisationODS": requesting_ods_code + } + assert ( + published_event["type"] + == "pathology-laboratory-reporting-test-result-stored-1" + ) + assert published_event["source"] == "uk.nhs.pathology-laboratory-reporting" + assert published_event["specversion"] == "1.0" + def test_no_payload_returns_error(self, client: Client) -> None: response = client.send_without_payload( request_method="POST", path="FHIR/R4/Bundle" diff --git a/pathology-api/tests/mock_client.py b/pathology-api/tests/mock_client.py index c1668f22..0a568d27 100644 --- a/pathology-api/tests/mock_client.py +++ b/pathology-api/tests/mock_client.py @@ -1,5 +1,5 @@ from datetime import timedelta -from typing import Any, TypedDict +from typing import Any, TypedDict, cast import requests @@ -30,3 +30,26 @@ def retrieve_sent_request(self, request_id: str) -> Any: cert=certs, ) return response.json() + + +class MNSMockClient: + def __init__( + self, url: str, timeout: timedelta, client_cert: CertificateDetails | None + ): + self._url = url + self._timeout = timeout + self._client_cert = client_cert + + def retrieve_sent_messages(self, subject: str) -> list[Any]: + certs = ( + (self._client_cert["cert_path"], self._client_cert["key_path"]) + if self._client_cert + else None + ) + + response = requests.get( + self._url + subject, + timeout=self._timeout.total_seconds(), + cert=certs, + ) + return cast("list[Any]", response.json().get("events", [])) From 3e748e104dc642ae20596f719f4e569969b72e52 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:45:19 +0000 Subject: [PATCH 03/25] [CDAPI-148]: Added new environment variables for PDM and MNS mock clients to integration test action --- .github/actions/run-test-suite/action.yaml | 31 ++++++++++++++++++++++ .github/workflows/preview-env.yaml | 4 +++ pathology-api/tests/conftest.py | 16 +++++------ pathology-api/tests/mock_client.py | 18 ++++++++----- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/.github/actions/run-test-suite/action.yaml b/.github/actions/run-test-suite/action.yaml index d84e9ae5..1cbb719f 100644 --- a/.github/actions/run-test-suite/action.yaml +++ b/.github/actions/run-test-suite/action.yaml @@ -17,6 +17,22 @@ inputs: description: "Environment: local or remote" required: false default: "remote" + apim_client_key_secret_name: + description: "Secret name of the APIM client certificate key (if needed)" + required: false + default: "" + apim_client_cert_secret_name: + description: "Secret name of the APIM client certificate (if needed)" + required: false + default: "" + pdm_mock_document_url: + description: "PDM mock document URL (if needed)" + required: false + default: "" + mns_mock_events_url: + description: "MNS mock events URL (if needed)" + required: false + default: "" runs: using: composite @@ -27,10 +43,25 @@ runs: APIGEE_ACCESS_TOKEN: ${{ inputs.apigee-access-token }} ENV: ${{ inputs.env }} TEST_TYPE: ${{ inputs.test-type }} + CLIENT_KEY_NAME: ${{ inputs.apim_client_key_secret_name }} + CLIENT_CERT_NAME: ${{ inputs.apim_client_cert_secret_name }} + PDM_MOCK_DOCUMENT_URL: ${{ inputs.pdm_mock_document_url }} + MNS_MOCK_EVENTS_URL: ${{ inputs.mns_mock_events_url }} run: | if [[ -n "${APIGEE_ACCESS_TOKEN}" ]]; then echo "::add-mask::${APIGEE_ACCESS_TOKEN}" fi + + if [[ -n "${CLIENT_KEY_NAME}" ]]; then + echo "Using APIM client certificate key from name: ${CLIENT_KEY_NAME}" + export CLIENT_KEY=$(printf '%s' "$CLIENT_KEY_NAME") + fi + + if [[ -n "${CLIENT_CERT_NAME}" ]]; then + echo "Using APIM client certificate from name: ${CLIENT_CERT_NAME}" + export CLIENT_CERT=$(printf '%s' "$CLIENT_CERT_NAME") + fi + make test-${TEST_TYPE} - name: "Upload ${{ inputs.test-type }} test results" diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index c2d318c7..c337d4ea 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -680,6 +680,10 @@ jobs: with: test-type: integration apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }} + apim_client_key_secret_name: $_cds_pathology_dev_mtls_client1_key_secret + apim_client_cert_secret_name: $_cds_pathology_dev_mtls_client1_key_public + pdm_mock_document_url: ${{ steps.names.outputs.mock_preview_url }}/pdm/mock/Bundle + mns_mock_events_url: ${{ steps.names.outputs.mock_preview_url }}/mns/mock/event - name: "Run acceptance tests" if: github.event.action != 'closed' diff --git a/pathology-api/tests/conftest.py b/pathology-api/tests/conftest.py index 59de7b1f..d1c6b892 100644 --- a/pathology-api/tests/conftest.py +++ b/pathology-api/tests/conftest.py @@ -279,21 +279,21 @@ def client_cert() -> Generator[CertificateDetails | None, None, None]: @pytest.fixture(scope="module") -def pdm_mock_url() -> str: - return _fetch_env_variable("PDM_MOCK_URL", str) +def pdm_mock_document_url() -> str: + return _fetch_env_variable("PDM_MOCK_DOCUMENT_URL", str) @pytest.fixture(scope="module") -def mns_mock_url() -> str: - return _fetch_env_variable("MNS_MOCK_URL", str) +def mns_mock_events_url() -> str: + return _fetch_env_variable("MNS_MOCK_EVENTS_URL", str) @pytest.fixture(scope="module") def pdm_mock_client( - client_cert: CertificateDetails | None, pdm_mock_url: str + client_cert: CertificateDetails | None, pdm_mock_document_url: str ) -> PDMMockClient: return PDMMockClient( - url=pdm_mock_url, + document_url=pdm_mock_document_url, timeout=timedelta(seconds=5), client_cert=client_cert, ) @@ -301,10 +301,10 @@ def pdm_mock_client( @pytest.fixture(scope="module") def mns_mock_client( - client_cert: CertificateDetails | None, mns_mock_url: str + client_cert: CertificateDetails | None, mns_mock_events_url: str ) -> MNSMockClient: return MNSMockClient( - url=mns_mock_url, + events_url=mns_mock_events_url, timeout=timedelta(seconds=5), client_cert=client_cert, ) diff --git a/pathology-api/tests/mock_client.py b/pathology-api/tests/mock_client.py index 0a568d27..743c24b3 100644 --- a/pathology-api/tests/mock_client.py +++ b/pathology-api/tests/mock_client.py @@ -11,9 +11,12 @@ class CertificateDetails(TypedDict): class PDMMockClient: def __init__( - self, url: str, timeout: timedelta, client_cert: CertificateDetails | None + self, + document_url: str, + timeout: timedelta, + client_cert: CertificateDetails | None, ): - self._url = url + self._document_url = document_url self._timeout = timeout self._client_cert = client_cert @@ -25,7 +28,7 @@ def retrieve_sent_request(self, request_id: str) -> Any: ) response = requests.get( - self._url + request_id, + self._document_url + "/" + request_id, timeout=self._timeout.total_seconds(), cert=certs, ) @@ -34,9 +37,12 @@ def retrieve_sent_request(self, request_id: str) -> Any: class MNSMockClient: def __init__( - self, url: str, timeout: timedelta, client_cert: CertificateDetails | None + self, + events_url: str, + timeout: timedelta, + client_cert: CertificateDetails | None, ): - self._url = url + self._events_url = events_url self._timeout = timeout self._client_cert = client_cert @@ -48,7 +54,7 @@ def retrieve_sent_messages(self, subject: str) -> list[Any]: ) response = requests.get( - self._url + subject, + self._events_url + "?subject=" + subject, timeout=self._timeout.total_seconds(), cert=certs, ) From bbff21e9736e7f3fdf9695f658aa1a90aa6a7f6e Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:37:32 +0000 Subject: [PATCH 04/25] [CDAPI-148]: Moved new mock clients into integration specific conftest file --- pathology-api/tests/conftest.py | 93 +++++---------------- pathology-api/tests/integration/conftest.py | 63 ++++++++++++++ 2 files changed, 86 insertions(+), 70 deletions(-) create mode 100644 pathology-api/tests/integration/conftest.py diff --git a/pathology-api/tests/conftest.py b/pathology-api/tests/conftest.py index d1c6b892..e72ab43a 100644 --- a/pathology-api/tests/conftest.py +++ b/pathology-api/tests/conftest.py @@ -1,17 +1,14 @@ """Pytest configuration and shared fixtures for pathology API tests.""" import os -import tempfile -from collections.abc import Generator +from collections.abc import Callable from datetime import timedelta -from typing import Any, Literal, Protocol, cast +from typing import Any, Literal, Protocol import pytest import requests from dotenv import load_dotenv -from .mock_client import CertificateDetails, MNSMockClient, PDMMockClient - load_dotenv() type _RequestMethod = Literal["GET", "POST"] @@ -213,15 +210,32 @@ def _send( @pytest.fixture(scope="module") -def base_url() -> str: +def fetch_env_variable[T]() -> Callable[[str, type[T]], T]: + def _fetch_env_variable(name: str, required_type: type[T]) -> T: + value = os.getenv(name) + if not value: + raise ValueError(f"{name} environment variable is not set.") + + if not isinstance(value, required_type): + raise ValueError( + f"{name} environment variable is not required type {required_type}" + ) + + return value + + return _fetch_env_variable + + +@pytest.fixture(scope="module") +def base_url(fetch_env_variable: Callable[[str, type[str]], str]) -> str: """Retrieves the base URL of the currently deployed application.""" - return _fetch_env_variable("BASE_URL", str) + return fetch_env_variable("BASE_URL", str) @pytest.fixture -def hostname() -> str: +def hostname(fetch_env_variable: Callable[[str, type[str]], str]) -> str: """Retrieves the hostname of the currently deployed application.""" - return _fetch_env_variable("HOST", str) + return fetch_env_variable("HOST", str) @pytest.fixture @@ -256,60 +270,6 @@ def client(request: pytest.FixtureRequest, base_url: str) -> Client: raise ValueError(f"Unknown env: {env}") -@pytest.fixture(scope="module") -def client_cert() -> Generator[CertificateDetails | None, None, None]: - client_cert = _fetch_env_variable("CLIENT_CERT", str) - client_key = _fetch_env_variable("CLIENT_KEY", str) - - if client_cert and client_key: - with ( - tempfile.NamedTemporaryFile(delete=True) as cert_file, - tempfile.NamedTemporaryFile(delete=True) as key_file, - ): - cert_file.write(client_cert.encode()) - cert_file.flush() - key_file.write(client_key.encode()) - key_file.flush() - yield { - "cert_path": cert_file.name, - "key_path": key_file.name, - } - else: - yield None - - -@pytest.fixture(scope="module") -def pdm_mock_document_url() -> str: - return _fetch_env_variable("PDM_MOCK_DOCUMENT_URL", str) - - -@pytest.fixture(scope="module") -def mns_mock_events_url() -> str: - return _fetch_env_variable("MNS_MOCK_EVENTS_URL", str) - - -@pytest.fixture(scope="module") -def pdm_mock_client( - client_cert: CertificateDetails | None, pdm_mock_document_url: str -) -> PDMMockClient: - return PDMMockClient( - document_url=pdm_mock_document_url, - timeout=timedelta(seconds=5), - client_cert=client_cert, - ) - - -@pytest.fixture(scope="module") -def mns_mock_client( - client_cert: CertificateDetails | None, mns_mock_events_url: str -) -> MNSMockClient: - return MNSMockClient( - events_url=mns_mock_events_url, - timeout=timedelta(seconds=5), - client_cert=client_cert, - ) - - def _create_remote_client(request: pytest.FixtureRequest) -> RemoteClient: """Create a RemoteClient with auth headers chosen by test markers. @@ -338,13 +298,6 @@ def _create_remote_client(request: pytest.FixtureRequest) -> RemoteClient: ) -def _fetch_env_variable[T](name: str, _: type[T]) -> T: - value = os.getenv(name) - if not value: - raise ValueError(f"{name} environment variable is not set.") - return cast("T", value) - - def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption( "--env", diff --git a/pathology-api/tests/integration/conftest.py b/pathology-api/tests/integration/conftest.py new file mode 100644 index 00000000..8dc3aa5d --- /dev/null +++ b/pathology-api/tests/integration/conftest.py @@ -0,0 +1,63 @@ +import tempfile +from collections.abc import Callable, Generator +from datetime import timedelta + +import pytest + +from tests.mock_client import CertificateDetails, MNSMockClient, PDMMockClient + + +@pytest.fixture(scope="module") +def client_cert( + fetch_env_variable: Callable[[str, type[str]], str], +) -> Generator[CertificateDetails | None, None, None]: + client_cert = fetch_env_variable("CLIENT_CERT", str) + client_key = fetch_env_variable("CLIENT_KEY", str) + + if client_cert and client_key: + with ( + tempfile.NamedTemporaryFile(delete=True) as cert_file, + tempfile.NamedTemporaryFile(delete=True) as key_file, + ): + cert_file.write(client_cert.encode()) + cert_file.flush() + key_file.write(client_key.encode()) + key_file.flush() + yield { + "cert_path": cert_file.name, + "key_path": key_file.name, + } + else: + yield None + + +@pytest.fixture(scope="module") +def pdm_mock_document_url(fetch_env_variable: Callable[[str, type[str]], str]) -> str: + return fetch_env_variable("PDM_MOCK_DOCUMENT_URL", str) + + +@pytest.fixture(scope="module") +def mns_mock_events_url(fetch_env_variable: Callable[[str, type[str]], str]) -> str: + return fetch_env_variable("MNS_MOCK_EVENTS_URL", str) + + +@pytest.fixture(scope="module") +def pdm_mock_client( + client_cert: CertificateDetails | None, pdm_mock_document_url: str +) -> PDMMockClient: + return PDMMockClient( + document_url=pdm_mock_document_url, + timeout=timedelta(seconds=5), + client_cert=client_cert, + ) + + +@pytest.fixture(scope="module") +def mns_mock_client( + client_cert: CertificateDetails | None, mns_mock_events_url: str +) -> MNSMockClient: + return MNSMockClient( + events_url=mns_mock_events_url, + timeout=timedelta(seconds=5), + client_cert=client_cert, + ) From 12d0bcb0d71aebcc1f2a32b5b6038119fe44d5ad Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:09:07 +0000 Subject: [PATCH 05/25] [CDAPI-148]: Removed exports from `run-test-action` action --- .github/actions/run-test-suite/action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/run-test-suite/action.yaml b/.github/actions/run-test-suite/action.yaml index 1cbb719f..9f0edabe 100644 --- a/.github/actions/run-test-suite/action.yaml +++ b/.github/actions/run-test-suite/action.yaml @@ -54,12 +54,12 @@ runs: if [[ -n "${CLIENT_KEY_NAME}" ]]; then echo "Using APIM client certificate key from name: ${CLIENT_KEY_NAME}" - export CLIENT_KEY=$(printf '%s' "$CLIENT_KEY_NAME") + CLIENT_KEY=$(printf '%s' "$CLIENT_KEY_NAME") fi if [[ -n "${CLIENT_CERT_NAME}" ]]; then echo "Using APIM client certificate from name: ${CLIENT_CERT_NAME}" - export CLIENT_CERT=$(printf '%s' "$CLIENT_CERT_NAME") + CLIENT_CERT=$(printf '%s' "$CLIENT_CERT_NAME") fi make test-${TEST_TYPE} From b0372900b68d6c9d2ccf3078c3d1592b06bbcf81 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:31:21 +0000 Subject: [PATCH 06/25] [CDAPI-148]: Added actions for fetching secrets within run-test-suite action --- .github/actions/run-test-suite/action.yaml | 24 ++++++++++++++++++---- .github/workflows/preview-env.yaml | 7 +++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/actions/run-test-suite/action.yaml b/.github/actions/run-test-suite/action.yaml index 9f0edabe..38e662b6 100644 --- a/.github/actions/run-test-suite/action.yaml +++ b/.github/actions/run-test-suite/action.yaml @@ -37,6 +37,20 @@ inputs: runs: using: composite steps: + - name: Fetch Client Key + if: ${{ inputs.apim_client_key_secret_name != '' }} + id: fetch-client-key + uses: aws-actions/aws-secretsmanager-get-secrets@2cb1a461cbd4865ac4299648312e4704c646cd53 + with: + secret-ids: ${{ inputs.apim_client_key_secret_name }} + name-transformation: lowercase + - name: Fetch Client Certificate + if: ${{ inputs.apim_client_cert_secret_name != '' }} + id: fetch-client-cert + uses: aws-actions/aws-secretsmanager-get-secrets@2cb1a461cbd4865ac4299648312e4704c646cd53 + with: + secret-ids: ${{ inputs.apim_client_cert_secret_name }} + name-transformation: lowercase - name: "Run ${{ inputs.test-type }} tests" shell: bash env: @@ -53,13 +67,15 @@ runs: fi if [[ -n "${CLIENT_KEY_NAME}" ]]; then - echo "Using APIM client certificate key from name: ${CLIENT_KEY_NAME}" - CLIENT_KEY=$(printf '%s' "$CLIENT_KEY_NAME") + SECRET_NAME=${CLIENT_KEY_NAME//\//_} + echo "Using APIM client certificate key from name: ${SECRET_NAME}" + CLIENT_KEY=${!SECRET_NAME} fi if [[ -n "${CLIENT_CERT_NAME}" ]]; then - echo "Using APIM client certificate from name: ${CLIENT_CERT_NAME}" - CLIENT_CERT=$(printf '%s' "$CLIENT_CERT_NAME") + SECRET_NAME=${CLIENT_CERT_NAME//\//_} + echo "Using APIM client certificate from name: ${SECRET_NAME}" + CLIENT_CERT=${!SECRET_NAME} fi make test-${TEST_TYPE} diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index c337d4ea..2d330e4f 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -677,11 +677,14 @@ jobs: - name: "Run integration tests" if: github.event.action != 'closed' uses: ./.github/actions/run-test-suite + env: + API_MTLS_CERT: ${{ secrets.API_MTLS_CERT }} + API_MTLS_KEY: ${{ secrets.API_MTLS_KEY }} with: test-type: integration apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }} - apim_client_key_secret_name: $_cds_pathology_dev_mtls_client1_key_secret - apim_client_cert_secret_name: $_cds_pathology_dev_mtls_client1_key_public + apim_client_key_secret_name: "${{ env.API_MTLS_CERT || '/cds/pathology/dev/mtls/client1-key-public' }}" + apim_client_cert_secret_name: "${{ env.API_MTLS_KEY || '/cds/pathology/dev/mtls/client1-key-secret' }}" pdm_mock_document_url: ${{ steps.names.outputs.mock_preview_url }}/pdm/mock/Bundle mns_mock_events_url: ${{ steps.names.outputs.mock_preview_url }}/mns/mock/event From 9399f50350700ece0b33ebb5a95fba865ca03cbd Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:43:11 +0000 Subject: [PATCH 07/25] [CDAPI-148]: Removed unnecessary secret retrieval from run-test-suite action --- .github/actions/run-test-suite/action.yaml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/.github/actions/run-test-suite/action.yaml b/.github/actions/run-test-suite/action.yaml index 38e662b6..cf2e8c41 100644 --- a/.github/actions/run-test-suite/action.yaml +++ b/.github/actions/run-test-suite/action.yaml @@ -37,20 +37,6 @@ inputs: runs: using: composite steps: - - name: Fetch Client Key - if: ${{ inputs.apim_client_key_secret_name != '' }} - id: fetch-client-key - uses: aws-actions/aws-secretsmanager-get-secrets@2cb1a461cbd4865ac4299648312e4704c646cd53 - with: - secret-ids: ${{ inputs.apim_client_key_secret_name }} - name-transformation: lowercase - - name: Fetch Client Certificate - if: ${{ inputs.apim_client_cert_secret_name != '' }} - id: fetch-client-cert - uses: aws-actions/aws-secretsmanager-get-secrets@2cb1a461cbd4865ac4299648312e4704c646cd53 - with: - secret-ids: ${{ inputs.apim_client_cert_secret_name }} - name-transformation: lowercase - name: "Run ${{ inputs.test-type }} tests" shell: bash env: From a0ad58a5309c91e4bcf0976b3cdf687e5c369072 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:08:30 +0000 Subject: [PATCH 08/25] [CDAPI-148]: Added in replacements for dashes within secret names --- .github/actions/run-test-suite/action.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/actions/run-test-suite/action.yaml b/.github/actions/run-test-suite/action.yaml index cf2e8c41..9d745963 100644 --- a/.github/actions/run-test-suite/action.yaml +++ b/.github/actions/run-test-suite/action.yaml @@ -54,12 +54,14 @@ runs: if [[ -n "${CLIENT_KEY_NAME}" ]]; then SECRET_NAME=${CLIENT_KEY_NAME//\//_} + SECRET_NAME=${SECRET_NAME//-/_} echo "Using APIM client certificate key from name: ${SECRET_NAME}" CLIENT_KEY=${!SECRET_NAME} fi if [[ -n "${CLIENT_CERT_NAME}" ]]; then SECRET_NAME=${CLIENT_CERT_NAME//\//_} + SECRET_NAME=${SECRET_NAME//-/_} echo "Using APIM client certificate from name: ${SECRET_NAME}" CLIENT_CERT=${!SECRET_NAME} fi From df793cb5263fa02cc4b9216814fbe78f56852662 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:20:01 +0000 Subject: [PATCH 09/25] [CDAPI-148]: Added environment variables to make test command --- .github/actions/run-test-suite/action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/run-test-suite/action.yaml b/.github/actions/run-test-suite/action.yaml index 9d745963..38c7466e 100644 --- a/.github/actions/run-test-suite/action.yaml +++ b/.github/actions/run-test-suite/action.yaml @@ -66,7 +66,7 @@ runs: CLIENT_CERT=${!SECRET_NAME} fi - make test-${TEST_TYPE} + CLIENT_KEY=${CLIENT_KEY} CLIENT_CERT=${CLIENT_CERT} make test-${TEST_TYPE} - name: "Upload ${{ inputs.test-type }} test results" if: always() From 68e997430a23e780ef54b5a1347c1b27f44ce6ba Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:43:15 +0000 Subject: [PATCH 10/25] [CDAPI-148]: Fixed fixture name within integration test --- pathology-api/tests/integration/test_endpoints.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index c640922d..0c62ab55 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -22,7 +22,7 @@ def test_bundle_returns_200( build_valid_test_result: Callable[[str, str], Bundle], pdm_mock_client: PDMMockClient, mns_mock_client: MNSMockClient, - pdm_mock_url: str, + pdm_mock_document_url: str, ) -> None: subject = "nhs_number" requesting_ods_code = "ods_code" @@ -69,7 +69,7 @@ def test_bundle_returns_200( published_event = published_events[0] assert published_event["subject"] == subject - assert published_event["dataref"] == pdm_mock_url + response_bundle.id + assert published_event["dataref"] == pdm_mock_document_url + response_bundle.id assert published_event["filtering"] == { "requestingOrganisationODS": requesting_ods_code } From 7f84fbba6e130fb0d12697fb10608348175458e3 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:50:41 +0000 Subject: [PATCH 11/25] [CDAPI-148]: Swapped around mTLS key and cert parameter --- .github/workflows/preview-env.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index 2d330e4f..afe71511 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -683,8 +683,8 @@ jobs: with: test-type: integration apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }} - apim_client_key_secret_name: "${{ env.API_MTLS_CERT || '/cds/pathology/dev/mtls/client1-key-public' }}" - apim_client_cert_secret_name: "${{ env.API_MTLS_KEY || '/cds/pathology/dev/mtls/client1-key-secret' }}" + apim_client_cert_secret_name: "${{ env.API_MTLS_CERT || '/cds/pathology/dev/mtls/client1-key-public' }}" + apim_client_key_secret_name: "${{ env.API_MTLS_KEY || '/cds/pathology/dev/mtls/client1-key-secret' }}" pdm_mock_document_url: ${{ steps.names.outputs.mock_preview_url }}/pdm/mock/Bundle mns_mock_events_url: ${{ steps.names.outputs.mock_preview_url }}/mns/mock/event From 8ba2d671bc96dd698955efa2786d06e1ea7a4aa4 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:34:59 +0000 Subject: [PATCH 12/25] [CDAPI-148]: Updated PDM mock to store received document instead of created document --- mocks/src/pdm_mock/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocks/src/pdm_mock/handler.py b/mocks/src/pdm_mock/handler.py index 3b26f27e..dcedc167 100644 --- a/mocks/src/pdm_mock/handler.py +++ b/mocks/src/pdm_mock/handler.py @@ -114,7 +114,7 @@ def handle_post_request(payload: dict[str, Any]) -> PDMResponse: item: DocumentItem = { "sessionId": document_id, "expiresAt": int(time()) + DEFAULT_TTL, - "document": json.dumps(created_document), + "document": json.dumps(payload), "type": "pdm_document", } From 3702ae8226f4a36995b41c8034107cc9626e8a51 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:54:59 +0000 Subject: [PATCH 13/25] [CDAPI-148]: Dumped retrieved PDM request as a JSON string for comparison --- pathology-api/tests/integration/test_endpoints.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index 0c62ab55..e7d8d3b0 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -61,7 +61,9 @@ def test_bundle_returns_200( assert response.headers["etag"] == 'W/"1"' - sent_request = pdm_mock_client.retrieve_sent_request(response_bundle.id) + sent_request = json.dumps( + pdm_mock_client.retrieve_sent_request(response_bundle.id) + ) assert sent_request == bundle.model_dump_json(by_alias=True) published_events = mns_mock_client.retrieve_sent_messages(subject) From 37fb98065d19b57b42c401bd9e6931b7afa1178f Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:18:33 +0000 Subject: [PATCH 14/25] [CDAPI-148]: Swapped assertion within test_endpoints.py to compare dicts instead of JSON strings --- pathology-api/tests/integration/test_endpoints.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index e7d8d3b0..979ed74a 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -61,10 +61,8 @@ def test_bundle_returns_200( assert response.headers["etag"] == 'W/"1"' - sent_request = json.dumps( - pdm_mock_client.retrieve_sent_request(response_bundle.id) - ) - assert sent_request == bundle.model_dump_json(by_alias=True) + sent_request = pdm_mock_client.retrieve_sent_request(response_bundle.id) + assert sent_request == bundle.model_dump(by_alias=True) published_events = mns_mock_client.retrieve_sent_messages(subject) assert len(published_events) == 1 From f107c39eed71d8b0abc596c73b21d306a87cc000 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:34:03 +0000 Subject: [PATCH 15/25] [CDAPI-148]: Excluded None values from sent request comparison --- pathology-api/tests/integration/test_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index 979ed74a..bb64b76d 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -62,7 +62,7 @@ def test_bundle_returns_200( assert response.headers["etag"] == 'W/"1"' sent_request = pdm_mock_client.retrieve_sent_request(response_bundle.id) - assert sent_request == bundle.model_dump(by_alias=True) + assert sent_request == bundle.model_dump(by_alias=True, exclude_none=True) published_events = mns_mock_client.retrieve_sent_messages(subject) assert len(published_events) == 1 From a2657f8a3ac2153a2fc63a6c8431c1832230fc7b Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:46:59 +0000 Subject: [PATCH 16/25] [CDAPI-148]: Using unique subject identifier within integration test to avoid conflicts --- pathology-api/tests/integration/test_endpoints.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index bb64b76d..c4e774f5 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -1,6 +1,7 @@ """Integration tests for the pathology API using pytest.""" import json +import uuid from collections.abc import Callable from typing import Any, Literal @@ -24,7 +25,7 @@ def test_bundle_returns_200( mns_mock_client: MNSMockClient, pdm_mock_document_url: str, ) -> None: - subject = "nhs_number" + subject = "subject-" + str(uuid.uuid4()) requesting_ods_code = "ods_code" bundle = build_valid_test_result(subject, requesting_ods_code) From c13a360e85457c62b506ee21ce9df2db361409e0 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:00:54 +0000 Subject: [PATCH 17/25] [CDAPI-148]: Added PDM bundle URL to test suite action and integration tests --- .github/actions/run-test-suite/action.yaml | 5 +++++ .github/workflows/preview-env.yaml | 1 + pathology-api/tests/integration/conftest.py | 5 +++++ pathology-api/tests/integration/test_endpoints.py | 4 ++-- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/actions/run-test-suite/action.yaml b/.github/actions/run-test-suite/action.yaml index 38c7466e..a1eac393 100644 --- a/.github/actions/run-test-suite/action.yaml +++ b/.github/actions/run-test-suite/action.yaml @@ -33,6 +33,10 @@ inputs: description: "MNS mock events URL (if needed)" required: false default: "" + pdm_bundle_url: + description: "PDM bundle URL (if needed)" + required: false + default: "" runs: using: composite @@ -47,6 +51,7 @@ runs: CLIENT_CERT_NAME: ${{ inputs.apim_client_cert_secret_name }} PDM_MOCK_DOCUMENT_URL: ${{ inputs.pdm_mock_document_url }} MNS_MOCK_EVENTS_URL: ${{ inputs.mns_mock_events_url }} + PDM_BUNDLE_URL: ${{ inputs.pdm_bundle_url }} run: | if [[ -n "${APIGEE_ACCESS_TOKEN}" ]]; then echo "::add-mask::${APIGEE_ACCESS_TOKEN}" diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index afe71511..4b9d5c83 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -687,6 +687,7 @@ jobs: apim_client_key_secret_name: "${{ env.API_MTLS_KEY || '/cds/pathology/dev/mtls/client1-key-secret' }}" pdm_mock_document_url: ${{ steps.names.outputs.mock_preview_url }}/pdm/mock/Bundle mns_mock_events_url: ${{ steps.names.outputs.mock_preview_url }}/mns/mock/event + pdm_bundle_url: ${{ steps.names.outputs.int_preview_url }}/pdm/FHIR/R4/Bundle - name: "Run acceptance tests" if: github.event.action != 'closed' diff --git a/pathology-api/tests/integration/conftest.py b/pathology-api/tests/integration/conftest.py index 8dc3aa5d..737e29b3 100644 --- a/pathology-api/tests/integration/conftest.py +++ b/pathology-api/tests/integration/conftest.py @@ -41,6 +41,11 @@ def mns_mock_events_url(fetch_env_variable: Callable[[str, type[str]], str]) -> return fetch_env_variable("MNS_MOCK_EVENTS_URL", str) +@pytest.fixture(scope="module") +def pdm_bundle_url(fetch_env_variable: Callable[[str, type[str]], str]) -> str: + return fetch_env_variable("PDM_BUNDLE_URL", str) + + @pytest.fixture(scope="module") def pdm_mock_client( client_cert: CertificateDetails | None, pdm_mock_document_url: str diff --git a/pathology-api/tests/integration/test_endpoints.py b/pathology-api/tests/integration/test_endpoints.py index c4e774f5..9dba3180 100644 --- a/pathology-api/tests/integration/test_endpoints.py +++ b/pathology-api/tests/integration/test_endpoints.py @@ -23,7 +23,7 @@ def test_bundle_returns_200( build_valid_test_result: Callable[[str, str], Bundle], pdm_mock_client: PDMMockClient, mns_mock_client: MNSMockClient, - pdm_mock_document_url: str, + pdm_bundle_url: str, ) -> None: subject = "subject-" + str(uuid.uuid4()) requesting_ods_code = "ods_code" @@ -70,7 +70,7 @@ def test_bundle_returns_200( published_event = published_events[0] assert published_event["subject"] == subject - assert published_event["dataref"] == pdm_mock_document_url + response_bundle.id + assert published_event["dataref"] == pdm_bundle_url + "/" + response_bundle.id assert published_event["filtering"] == { "requestingOrganisationODS": requesting_ods_code } From 5a95536bafe4df056116f317179bf03dac5d7244 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:13:12 +0000 Subject: [PATCH 18/25] [CDAPI-148]: Fixed supplied Bundle URL --- .github/workflows/preview-env.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index 4b9d5c83..f9e07890 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -687,7 +687,7 @@ jobs: apim_client_key_secret_name: "${{ env.API_MTLS_KEY || '/cds/pathology/dev/mtls/client1-key-secret' }}" pdm_mock_document_url: ${{ steps.names.outputs.mock_preview_url }}/pdm/mock/Bundle mns_mock_events_url: ${{ steps.names.outputs.mock_preview_url }}/mns/mock/event - pdm_bundle_url: ${{ steps.names.outputs.int_preview_url }}/pdm/FHIR/R4/Bundle + pdm_bundle_url: ${{ steps.names.outputs.mock_preview_url }}/pdm/FHIR/R4/Bundle - name: "Run acceptance tests" if: github.event.action != 'closed' From de232fd754323d85decc7d5eef68cb549d3b7827 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:06:24 +0000 Subject: [PATCH 19/25] [CDAPI-148]: Updated API gateway mock to include correlation ID and support query strings. --- .../images/api-gateway-mock/resources/server.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/infrastructure/images/api-gateway-mock/resources/server.py b/infrastructure/images/api-gateway-mock/resources/server.py index f6918366..cae77f10 100644 --- a/infrastructure/images/api-gateway-mock/resources/server.py +++ b/infrastructure/images/api-gateway-mock/resources/server.py @@ -44,7 +44,7 @@ def forward_request(path_params): x_correlation_id = request.headers.get("X-Correlation-ID", "") forwarded_headers = {k.lower(): v for k, v in request.headers.items()} - forwarded_headers["nhsd-correlation-id"] = x_correlation_id + forwarded_headers["nhsd-correlation-id"] = f".{x_correlation_id}.test" response = requests.post( f"{TARGET_URL}/2015-03-31/functions/function/invocations", @@ -61,8 +61,9 @@ def forward_request(path_params): }, "httpMethod": request.method, "rawPath": f"/{path_params}", - "rawQueryString": "", + "rawQueryString": request.query_string.decode("utf-8"), "pathParameters": {"proxy": path_params}, + "queryStringParameters": request.args.to_dict(), }, headers={"Content-Type": "application/json"}, timeout=120, @@ -75,11 +76,13 @@ def forward_request(path_params): app.logger.info("response: %s", response.text) response_data = response.json() + headers = {"x-correlation-id": x_correlation_id} | response_data.get("headers", {}) + output = ( ( response_data["body"], response_data["statusCode"], - response_data["headers"], + headers, ) if "body" in response_data else (response_data, 500, {"Content-Type": "text/plain"}) From 2e3da5ea4be9f6c740f19ad6d367101b1988cfeb Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:06:59 +0000 Subject: [PATCH 20/25] [CDAPI-148]: Updated conftest configuration to allow for no mtls cert Also increased timeout for local client. --- pathology-api/tests/conftest.py | 2 +- pathology-api/tests/integration/conftest.py | 9 ++++----- scripts/tests/test.mk | 3 ++- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pathology-api/tests/conftest.py b/pathology-api/tests/conftest.py index e72ab43a..99833973 100644 --- a/pathology-api/tests/conftest.py +++ b/pathology-api/tests/conftest.py @@ -59,7 +59,7 @@ def __init__( self, lambda_url: str, headers: dict[str, str] | None = None, - timeout: timedelta = timedelta(seconds=1), + timeout: timedelta = timedelta(seconds=10), ): self._lambda_url = lambda_url self._default_headers = {"Content-Type": "application/fhir+json"} | ( diff --git a/pathology-api/tests/integration/conftest.py b/pathology-api/tests/integration/conftest.py index 737e29b3..2b51b8b4 100644 --- a/pathology-api/tests/integration/conftest.py +++ b/pathology-api/tests/integration/conftest.py @@ -1,3 +1,4 @@ +import os import tempfile from collections.abc import Callable, Generator from datetime import timedelta @@ -8,11 +9,9 @@ @pytest.fixture(scope="module") -def client_cert( - fetch_env_variable: Callable[[str, type[str]], str], -) -> Generator[CertificateDetails | None, None, None]: - client_cert = fetch_env_variable("CLIENT_CERT", str) - client_key = fetch_env_variable("CLIENT_KEY", str) +def client_cert() -> Generator[CertificateDetails | None, None, None]: + client_cert = os.getenv("CLIENT_CERT") + client_key = os.getenv("CLIENT_KEY") if client_cert and client_key: with ( diff --git a/scripts/tests/test.mk b/scripts/tests/test.mk index c53b0cf6..ade75b80 100644 --- a/scripts/tests/test.mk +++ b/scripts/tests/test.mk @@ -138,8 +138,9 @@ env-remote: # Run tests against local lambda test-local: env-local + @echo "Running test stage: $${stage:-all}" @set -a && source .env && set +a && \ - $(MAKE) test + $(MAKE) test$(if $(stage),-$(stage),) # Run tests against remote lambda, exporting APIGEE_ACCESS_TOKEN only test-remote: env-remote From 88550130715fd68a5c4d11bebedb09674aa51e15 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Fri, 1 May 2026 08:52:48 +0000 Subject: [PATCH 21/25] [CDAPI-148]: Minor fixes to openapi.yaml examples and schema --- pathology-api/openapi.yaml | 47 ++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/pathology-api/openapi.yaml b/pathology-api/openapi.yaml index fb95451f..da87c7bf 100644 --- a/pathology-api/openapi.yaml +++ b/pathology-api/openapi.yaml @@ -232,10 +232,10 @@ paths: identifier: system: "https://fhir.nhs.uk/Id/nhs-number" value: "9999999999" - extension: - - url: "http://hl7.eu/fhir/StructureDefinition/composition-basedOn-order-or-requisition" - valueReference: - reference: "ServiceRequest" + extension: + - url: "http://hl7.eu/fhir/StructureDefinition/composition-basedOn-order-or-requisition" + valueReference: + reference: "ServiceRequest" - fullUrl: "ServiceRequest" resource: resourceType: ServiceRequest @@ -250,8 +250,8 @@ paths: resource: resourceType: Organization identifier: - system: "https://fhir.nhs.uk/Id/ods-organization-code" - value: "A12345" + - system: "https://fhir.nhs.uk/Id/ods-organization-code" + value: "A12345" - fullUrl: "Patient" resource: resourceType: Patient @@ -365,8 +365,8 @@ paths: resource: resourceType: Organization identifier: - system: "https://fhir.nhs.uk/Id/ods-organization-code" - value: "A12345" + - system: "https://fhir.nhs.uk/Id/ods-organization-code" + value: "A12345" - fullUrl: "Patient" resource: resourceType: Patient @@ -565,20 +565,23 @@ components: - Organization example: Organization identifier: - type: object - required: - - system - - value - properties: - system: - type: string - enum: - - "https://fhir.nhs.uk/Id/ods-organization-code" - example: "https://fhir.nhs.uk/Id/ods-organization-code" - value: - type: string - example: "A12345" - description: ODS code of the requesting organisation + type: array + minItems: 1 + items: + type: object + required: + - system + - value + properties: + system: + type: string + enum: + - "https://fhir.nhs.uk/Id/ods-organization-code" + example: "https://fhir.nhs.uk/Id/ods-organization-code" + value: + type: string + example: "A12345" + description: ODS code of the requesting organisation OperationOutcome: type: object required: From eb834fffe6b700aff6eb1d1e2673681b8d431af2 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Fri, 1 May 2026 11:27:20 +0000 Subject: [PATCH 22/25] [CDAPI-148]: Added configuration to fetch mTLS secrets from AWS Secrets Manager when running tests remotely --- scripts/fetch_secret.sh | 9 +++++++++ scripts/tests/test.mk | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100755 scripts/fetch_secret.sh diff --git a/scripts/fetch_secret.sh b/scripts/fetch_secret.sh new file mode 100755 index 00000000..485205de --- /dev/null +++ b/scripts/fetch_secret.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +secretName="$1" + +echo "Retrieving secret from AWS Secrets Manager: $secretName ..." >&2 + +SECRET_VALUE=$(aws secretsmanager get-secret-value --secret-id "$secretName" --query 'SecretString' --output text) +echo "${SECRET_VALUE}" diff --git a/scripts/tests/test.mk b/scripts/tests/test.mk index ade75b80..b5046954 100644 --- a/scripts/tests/test.mk +++ b/scripts/tests/test.mk @@ -148,6 +148,8 @@ test-remote: env-remote @echo "Obtaining APIGEE access token..." @set -a && source .env && set +a && \ APIGEE_ACCESS_TOKEN="$$(./scripts/get_apigee_token.sh)" && \ + CLIENT_CERT="$$(./scripts/fetch_secret.sh "$$APIM_CLIENT_CERT_SECRET_NAME")" && \ + CLIENT_KEY="$$(./scripts/fetch_secret.sh "$$APIM_CLIENT_KEY_SECRET_NAME")" && \ BASE_URL="$${BASE_URL}-pr-$${PR_NUMBER}" && \ - export APIGEE_ACCESS_TOKEN BASE_URL && \ + export APIGEE_ACCESS_TOKEN CLIENT_CERT CLIENT_KEY BASE_URL && \ $(MAKE) test$(if $(stage),-$(stage),) From 33e625047a0cccb4159dbf4cde863fb97577ca2b Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Tue, 5 May 2026 16:02:05 +0000 Subject: [PATCH 23/25] [CDAPI-148]: Added hooks and auth to schemathesis to ensure tests execute properly --- pathology-api/tests/conftest.py | 16 + pathology-api/tests/schema/conftest.py | 304 ++++++++++++++++-- .../tests/schema/test_openapi_schema.py | 10 +- schemathesis.toml | 3 + 4 files changed, 309 insertions(+), 24 deletions(-) diff --git a/pathology-api/tests/conftest.py b/pathology-api/tests/conftest.py index 99833973..a5738a02 100644 --- a/pathology-api/tests/conftest.py +++ b/pathology-api/tests/conftest.py @@ -51,6 +51,14 @@ def send_without_payload( """ ... + @property + def auth_headers(self) -> dict[str, str] | None: + """ + Return the authentication headers used by this client, or None if not + applicable. + """ + ... + class LocalClient: """HTTP client that sends requests to the Lambda via the RIE (no auth headers).""" @@ -98,6 +106,10 @@ def send_without_payload( headers=headers, ) + @property + def auth_headers(self) -> None: + return None + def _send( self, data: str | None, @@ -182,6 +194,10 @@ def send_without_payload( headers=headers, ) + @property + def auth_headers(self) -> dict[str, str]: + return self._default_headers + def _send( self, data: str | None, diff --git a/pathology-api/tests/schema/conftest.py b/pathology-api/tests/schema/conftest.py index 0bc35eca..9686c86b 100644 --- a/pathology-api/tests/schema/conftest.py +++ b/pathology-api/tests/schema/conftest.py @@ -1,7 +1,13 @@ +from collections.abc import Iterable from typing import Any import schemathesis +_SERVICE_REQUEST_EXTENSION_URL = ( + "http://hl7.eu/fhir/StructureDefinition/composition-basedOn-order-or-requisition" +) +_ODS_CODE_SYSTEM_URL = "https://fhir.nhs.uk/Id/ods-organization-code" + def _find_entries(body: dict[str, Any]) -> list[dict[str, Any]]: if "entry" in body and isinstance(body["entry"], list): @@ -9,16 +15,62 @@ def _find_entries(body: dict[str, Any]) -> list[dict[str, Any]]: return [] -def _find_compositions(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: +def _find_resources( + entries: Iterable[dict[str, Any]], resource_type: str +) -> list[dict[str, Any]]: return [ - item["resource"] + item for item in entries - if item.get("resource") is not None - and isinstance(item["resource"], dict) - and item["resource"].get("resourceType") == "Composition" + if (resource := item.get("resource")) is not None + and isinstance(resource, dict) + and resource.get("resourceType") == resource_type ] +def _find_resource_by_url(entries: Iterable[dict[str, Any]], url: str) -> Any | None: + return next( + ( + entry["resource"] + for entry in entries + if entry.get("fullUrl") == url and isinstance(entry.get("resource"), dict) + ), + None, + ) + + +def _validate_reference( + entries: Iterable[dict[str, Any]], reference: str, resource_type: str +) -> bool: + resource = _find_resource_by_url(entries, reference) + return resource is not None and resource.get("resourceType") == resource_type + + +def _find_extension(resource: dict[str, Any], url: str) -> Any | None: + if "extension" not in resource or not isinstance(resource["extension"], list): + return None + + return next( + ( + extension + for extension in resource["extension"] + if extension.get("url") == url + ), + None, + ) + + +def _add_missing_resource_if_required( + case: schemathesis.Case, resource_type: str, resource: dict[str, Any] +) -> schemathesis.Case: + if isinstance(case.body, dict): + entries = _find_entries(case.body) + if len(_find_resources(entries, resource_type)) == 0: + entries.append(resource) + case.body["entry"] = entries + + return case + + @schemathesis.hook("before_call") def ensure_composition_in_body( _ctx: schemathesis.HookContext, case: schemathesis.Case, *_kwargs: Any @@ -27,26 +79,234 @@ def ensure_composition_in_body( Hook to ensure that when schemathesis generates a request body, it always contains a Composition resource. """ + return _add_missing_resource_if_required( + case, + "Composition", + { + "fullUrl": "composition", + "resource": { + "resourceType": "Composition", + "subject": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "nhs_number", + } + }, + "extension": [ + { + "url": _SERVICE_REQUEST_EXTENSION_URL, + "valueReference": {"reference": "service_request"}, + } + ], + }, + }, + ) + + +@schemathesis.hook("before_call") +def ensure_service_request_in_body( + _ctx: schemathesis.HookContext, case: schemathesis.Case, *_kwargs: Any +) -> schemathesis.Case: + """ + Hook to ensure that when schemathesis generates a request body, + it always contains a ServiceRequest resource. + """ + return _add_missing_resource_if_required( + case, + "ServiceRequest", + { + "fullUrl": "service_request", + "resource": { + "resourceType": "ServiceRequest", + "subject": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "nhs_number", + } + }, + "requester": {"reference": "practitioner_role"}, + }, + }, + ) + + +@schemathesis.hook("before_call") +def ensure_practitioner_role_in_body( + _ctx: schemathesis.HookContext, case: schemathesis.Case, *_kwargs: Any +) -> schemathesis.Case: + """ + Hook to ensure that when schemathesis generates a request body, + it always contains a PractitionerRole resource. + """ + return _add_missing_resource_if_required( + case, + "PractitionerRole", + { + "fullUrl": "practitioner_role", + "resource": { + "resourceType": "PractitionerRole", + "organization": {"reference": "organization"}, + }, + }, + ) + + +@schemathesis.hook("before_call") +def ensure_organization_in_body( + _ctx: schemathesis.HookContext, case: schemathesis.Case, *_kwargs: Any +) -> schemathesis.Case: + """ + Hook to ensure that when schemathesis generates a request body, + it always contains an Organization resource. + """ + return _add_missing_resource_if_required( + case, + "Organization", + { + "fullUrl": "organization", + "resource": { + "resourceType": "Organization", + "identifier": [ + { + "system": _ODS_CODE_SYSTEM_URL, + "value": "ods_code", + } + ], + }, + }, + ) + + +def _ensure_organization_references( + entries: Iterable[dict[str, Any]], +) -> None: + for practitioner_role in _find_resources(entries, "PractitionerRole"): + if not _validate_reference( + entries, + practitioner_role["resource"]["organization"]["reference"], + "Organization", + ): + new_ref = _find_resources(entries, "Organization")[0]["fullUrl"] + practitioner_role["resource"]["organization"]["reference"] = new_ref + + +def _ensure_practitioner_role_references( + entries: Iterable[dict[str, Any]], +) -> None: + for service_request in _find_resources(entries, "ServiceRequest"): + if not _validate_reference( + entries, + service_request["resource"]["requester"]["reference"], + "PractitionerRole", + ): + service_request["resource"]["requester"]["reference"] = _find_resources( + entries, "PractitionerRole" + )[0]["fullUrl"] + + +def _ensure_service_request_references( + entries: Iterable[dict[str, Any]], +) -> None: + for composition in _find_resources(entries, "Composition"): + ext_ref = _find_extension( + composition["resource"], + _SERVICE_REQUEST_EXTENSION_URL, + ) + if ext_ref is None: + ext_ref = { + "url": _SERVICE_REQUEST_EXTENSION_URL, + "valueReference": {"reference": "unknown"}, + } + + composition["resource"].setdefault("extension", []).append(ext_ref) + + if not _validate_reference( + entries, + ext_ref["valueReference"]["reference"], + "ServiceRequest", + ): + ext_ref["valueReference"]["reference"] = _find_resources( + entries, "ServiceRequest" + )[0]["fullUrl"] + + +@schemathesis.hook("before_call") +def ensure_organization_includes_single_identifier( + _ctx: schemathesis.HookContext, case: schemathesis.Case, *_kwargs: Any +) -> schemathesis.Case: + """ + Hook to ensure that when schemathesis generates a request body, + any Organization resource included only contains a single identifier. + """ + + def _duplicate_identifiers(item: dict[str, Any]) -> bool: + return ( + len( + [ + identifier + for identifier in item["resource"].get("identifier", []) + if identifier.get("system") == _ODS_CODE_SYSTEM_URL + ] + ) + > 1 + ) + if isinstance(case.body, dict): entries = _find_entries(case.body) - if len(_find_compositions(entries)) == 0: - # If no Composition resource is found, add a valid entry to satisfy - # the schema - entries.append( + for organization in filter( + _duplicate_identifiers, + _find_resources(entries, "Organization"), + ): + organization["resource"]["identifier"] = [ { - "fullUrl": "composition", - "resource": { - "resourceType": "Composition", - "subject": { - "identifier": { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "nhs_number", - } - }, - }, + "system": _ODS_CODE_SYSTEM_URL, + "value": "ods_code", } - ) - case.body["entry"] = entries + ] + + case.body["entry"] = entries + + return case + + +@schemathesis.hook("before_call") +def ensure_non_duplicate_full_urls( + _ctx: schemathesis.HookContext, case: schemathesis.Case, *_kwargs: Any +) -> schemathesis.Case: + """ + Hook to ensure that when schemathesis generates a request body, + it does not contain duplicate fullUrl values. + """ + if isinstance(case.body, dict): + entries = _find_entries(case.body) + existing_full_urls = set[str]() + for item in entries: + full_url = item.get("fullUrl") or "None" + if full_url in existing_full_urls: + item["fullUrl"] = f"{full_url}_{id(item)}" + existing_full_urls.add(full_url) + + case.body["entry"] = entries + + return case + + +@schemathesis.hook("before_call") +def ensure_valid_references_in_body( + _ctx: schemathesis.HookContext, case: schemathesis.Case, *_kwargs: Any +) -> schemathesis.Case: + """ + Hook to ensure that when schemathesis generates a request body, + it contains valid references between resources. + """ + if isinstance(case.body, dict): + entries = _find_entries(case.body) + _ensure_organization_references(entries) + _ensure_practitioner_role_references(entries) + _ensure_service_request_references(entries) + + case.body["entry"] = entries + return case @@ -58,5 +318,5 @@ def ignore_multiple_composition_requests( Hook to filter out any requests generated by schemathesis that contain more than one Composition resource. """ - composition_resources = _find_compositions(_find_entries(body)) + composition_resources = _find_resources(_find_entries(body), "Composition") return len(composition_resources) < 2 diff --git a/pathology-api/tests/schema/test_openapi_schema.py b/pathology-api/tests/schema/test_openapi_schema.py index 29c5c75a..bc2c06c9 100644 --- a/pathology-api/tests/schema/test_openapi_schema.py +++ b/pathology-api/tests/schema/test_openapi_schema.py @@ -7,9 +7,12 @@ from pathlib import Path import yaml +from hypothesis import HealthCheck, settings from schemathesis.generation.case import Case from schemathesis.openapi import from_dict +from tests.conftest import Client + # Load the OpenAPI schema from the local file schema_path = Path(__file__).parent.parent.parent / "openapi.yaml" with open(schema_path) as f: @@ -18,7 +21,10 @@ @schema.parametrize() -def test_api_schema_compliance(case: Case, base_url: str) -> None: +# Allowing client even though function scoped. Same auth can be safely +# used for all schema testing. +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) +def test_api_schema_compliance(case: Case, base_url: str, client: Client) -> None: """Test API endpoints against the OpenAPI schema. Schemathesis automatically generates test cases with: @@ -34,4 +40,4 @@ def test_api_schema_compliance(case: Case, base_url: str) -> None: - Returns appropriate status codes """ # Call the API and validate the response against the schema - case.call_and_validate(base_url=base_url, timeout=30) + case.call_and_validate(base_url=base_url, timeout=30, headers=client.auth_headers) diff --git a/schemathesis.toml b/schemathesis.toml index a9d4ec39..969f7959 100644 --- a/schemathesis.toml +++ b/schemathesis.toml @@ -1,4 +1,7 @@ request-timeout = 20.0 +[checks] +positive_data_acceptance.expected-statuses = [200] + [generation] mode = "positive" From 2a8721f2d05718c8f3843de81ea1936f975a3e30 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Tue, 5 May 2026 16:32:27 +0000 Subject: [PATCH 24/25] [CDAPI-148]: Updated vscode settings to no longer apply the Python environment in the terminal --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 6069540f..2ba325f5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -78,5 +78,6 @@ }, // Disabling automatic port forwarding as the devcontainer should already have access to any required ports. "remote.autoForwardPorts": false, - "python-envs.defaultEnvManager": "ms-python.python:system" + "python-envs.defaultEnvManager": "ms-python.python:pyenv", + "python.terminal.activateEnvironment": false } From 15df7e814c5e654120ee79f10b793e124f23e03a Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Tue, 5 May 2026 17:12:25 +0000 Subject: [PATCH 25/25] [CDAPI-148]: Added hooks to ensure empty subject and requesters are not provided --- pathology-api/tests/schema/conftest.py | 71 +++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/pathology-api/tests/schema/conftest.py b/pathology-api/tests/schema/conftest.py index 9686c86b..09d4031f 100644 --- a/pathology-api/tests/schema/conftest.py +++ b/pathology-api/tests/schema/conftest.py @@ -7,6 +7,7 @@ "http://hl7.eu/fhir/StructureDefinition/composition-basedOn-order-or-requisition" ) _ODS_CODE_SYSTEM_URL = "https://fhir.nhs.uk/Id/ods-organization-code" +_NHS_NUMBER_SYSTEM_URL = "https://fhir.nhs.uk/Id/nhs-number" def _find_entries(body: dict[str, Any]) -> list[dict[str, Any]]: @@ -88,7 +89,7 @@ def ensure_composition_in_body( "resourceType": "Composition", "subject": { "identifier": { - "system": "https://fhir.nhs.uk/Id/nhs-number", + "system": _NHS_NUMBER_SYSTEM_URL, "value": "nhs_number", } }, @@ -120,7 +121,7 @@ def ensure_service_request_in_body( "resourceType": "ServiceRequest", "subject": { "identifier": { - "system": "https://fhir.nhs.uk/Id/nhs-number", + "system": _NHS_NUMBER_SYSTEM_URL, "value": "nhs_number", } }, @@ -310,6 +311,72 @@ def ensure_valid_references_in_body( return case +@schemathesis.hook("before_call") +def ensure_organization_has_valid_identifier( + _ctx: schemathesis.HookContext, case: schemathesis.Case, **_kwargs: Any +) -> schemathesis.Case: + """ + Hook to ensure that when schemathesis generates a request body, + any Organization resource included contains an identifier with the correct system. + """ + + def __is_missing_identifier(item: dict[str, Any]) -> bool: + + ods_identifiers = [ + identifier + for identifier in item["resource"].get("identifier", []) + if identifier.get("system") == _ODS_CODE_SYSTEM_URL + and identifier.get("value") + ] + return len(ods_identifiers) == 0 + + if isinstance(case.body, dict): + entries = _find_entries(case.body) + for organization in filter( + __is_missing_identifier, _find_resources(entries, "Organization") + ): + identifiers = organization["resource"].setdefault("identifier", []) + identifiers.append( + { + "system": _ODS_CODE_SYSTEM_URL, + "value": "ods_code", + } + ) + + case.body["entry"] = entries + + return case + + +@schemathesis.hook("before_call") +def ensure_composition_includes_subject_identifier( + _ctx: schemathesis.HookContext, case: schemathesis.Case, **_kwargs: Any +) -> schemathesis.Case: + """ + Hook to ensure that when schemathesis generates a request body, + any Composition resource included contains a subject with an identifier. + """ + + def __is_missing_subject_identifier(item: dict[str, Any]) -> bool: + identifier = item["resource"]["subject"]["identifier"] + return not identifier.get("value") + + if isinstance(case.body, dict): + entries = _find_entries(case.body) + for composition in filter( + __is_missing_subject_identifier, + _find_resources(entries, "Composition"), + ): + composition["resource"]["subject"]["identifier"] = { + "system": _NHS_NUMBER_SYSTEM_URL, + "value": "nhs_number", + } + + case.body["entry"] = entries + + return case + + @schemathesis.hook("filter_body") def ignore_multiple_composition_requests( _: schemathesis.HookContext, body: dict[str, Any]