From 735dd399bb07d72eacbbb58c58b036c17c3b9443 Mon Sep 17 00:00:00 2001 From: russellpollock Date: Thu, 5 Feb 2026 12:07:36 +0000 Subject: [PATCH 1/2] [GPCAPIM-260]-[Steel Thread integration testing]-[RP] --- .github/workflows/preview-env.yml | 164 +++++++++++++++++- .../acceptance/features/hello_world.feature | 16 -- .../tests/acceptance/steps/happy_path.py | 1 + gateway-api/tests/conftest.py | 10 +- gateway-api/tests/contract/conftest.py | 94 ++++++++++ .../tests/contract/test_consumer_contract.py | 37 +--- .../tests/contract/test_provider_contract.py | 31 +--- .../tests/schema/test_openapi_schema.py | 18 +- 8 files changed, 294 insertions(+), 77 deletions(-) delete mode 100644 gateway-api/tests/acceptance/features/hello_world.feature create mode 100644 gateway-api/tests/contract/conftest.py diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 6cb7147e..b5af88bc 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -234,6 +234,14 @@ jobs: /cds/gateway/dev/mtls/client1-key-public name-transformation: lowercase + # Prepare cert files for the following test suites + - name: Prepare mTLS cert files for tests + if: github.event.action != 'closed' + run: | + printf '%s' "$_cds_gateway_dev_mtls_client1_key_secret" > /tmp/client1-key.pem + printf '%s' "$_cds_gateway_dev_mtls_client1_key_public" > /tmp/client1-cert.pem + chmod 600 /tmp/client1-key.pem /tmp/client1-cert.pem + - name: Smoke test preview URL if: github.event.action != 'closed' id: smoke-test @@ -247,9 +255,6 @@ jobs: exit 0 fi - # Reachability check: allow 404 (app routes might not exist yet) but fail otherwise - printf '%s' "$_cds_gateway_dev_mtls_client1_key_secret" > /tmp/client1-key.pem - printf '%s' "$_cds_gateway_dev_mtls_client1_key_public" > /tmp/client1-cert.pem STATUS=$(curl \ --cert /tmp/client1-cert.pem \ --key /tmp/client1-key.pem \ @@ -258,8 +263,6 @@ jobs: --write-out '%{http_code}' \ --head \ --max-time 30 "$PREVIEW_URL"/health || true) - rm -f /tmp/client1-key.pem - rm -f /tmp/client1-cert.pem if [ "$STATUS" = "404" ]; then echo "Preview responded with expected 404" @@ -285,6 +288,156 @@ jobs: echo "http_result=unexpected-status" >> "$GITHUB_OUTPUT" exit 0 + # ---------- QUALITY CHECKS (Test Suites) ---------- + + # UNIT TESTS + - name: Run unit tests + if: github.event.action != 'closed' + run: make test-unit + + - name: Upload unit test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: unit-test-results + path: gateway-api/test-artefacts/ + retention-days: 30 + + - name: Check unit-tests.xml exists + id: check-unit + if: always() + run: | + [ -f "gateway-api/test-artefacts/unit-tests.xml" ] && echo "exists=true" >> "$GITHUB_OUTPUT" || echo "exists=false" >> "$GITHUB_OUTPUT" + + + - name: Publish unit test results to summary + if: ${{ always() && steps.check-unit.outputs.exists == 'true' }} + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 + with: + paths: gateway-api/test-artefacts/unit-tests.xml + + # CONTRACT TESTS + - name: Run contract tests against preview + if: github.event.action != 'closed' + env: + BASE_URL: ${{ steps.tf-output.outputs.preview_url }} + MTLS_CERT: /tmp/client1-cert.pem + MTLS_KEY: /tmp/client1-key.pem + run: make test-contract + + - name: Upload contract test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: contract-test-results + path: gateway-api/test-artefacts/ + retention-days: 30 + + - name: Check contract-tests.xml exists + id: check-contract + if: always() + run: | + [ -f "gateway-api/test-artefacts/contract-tests.xml" ] && echo "exists=true" >> "$GITHUB_OUTPUT" || echo "exists=false" >> "$GITHUB_OUTPUT" + + + - name: Publish contract test results to summary + if: ${{ always() && steps.check-contract.outputs.exists == 'true' }} + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 + with: + paths: gateway-api/test-artefacts/contract-tests.xml + + # SCHEMA TESTS + - name: Run schema validation against preview + if: github.event.action != 'closed' + env: + BASE_URL: ${{ steps.tf-output.outputs.preview_url }} + MTLS_CERT: /tmp/client1-cert.pem + MTLS_KEY: /tmp/client1-key.pem + run: make test-schema + + - name: Upload schema test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: schema-test-results + path: gateway-api/test-artefacts/ + retention-days: 30 + + - name: Check schema-tests.xml exists + id: check-schema + if: always() + run: | + [ -f "gateway-api/test-artefacts/schema-tests.xml" ] && echo "exists=true" >> "$GITHUB_OUTPUT" || echo "exists=false" >> "$GITHUB_OUTPUT" + + - name: Publish schema test results to summary + if: ${{ always() && steps.check-schema.outputs.exists == 'true' }} + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 + with: + paths: gateway-api/test-artefacts/schema-tests.xml + + # INTEGRATION TESTS + - name: Run integration tests against preview + if: github.event.action != 'closed' + env: + BASE_URL: ${{ steps.tf-output.outputs.preview_url }} + MTLS_CERT: /tmp/client1-cert.pem + MTLS_KEY: /tmp/client1-key.pem + run: make test-integration + + - name: Upload integration test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: integration-test-results + path: gateway-api/test-artefacts/ + retention-days: 30 + + - name: Check integration-tests.xml exists + id: check-integration + if: always() + run: | + [ -f "gateway-api/test-artefacts/integration-tests.xml" ] && echo "exists=true" >> "$GITHUB_OUTPUT" || echo "exists=false" >> "$GITHUB_OUTPUT" + + - name: Publish integration test results to summary + if: ${{ always() && steps.check-integration.outputs.exists == 'true' }} + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 + with: + paths: gateway-api/test-artefacts/integration-tests.xml + + # ACCEPTANCE TESTS + - name: Run acceptance tests against preview + if: github.event.action != 'closed' + env: + BASE_URL: ${{ steps.tf-output.outputs.preview_url }} + MTLS_CERT: /tmp/client1-cert.pem + MTLS_KEY: /tmp/client1-key.pem + run: make test-acceptance + + - name: Upload acceptance test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: acceptance-test-results + path: gateway-api/test-artefacts/ + retention-days: 30 + + - name: Check acceptance-tests.xml exists + id: check-acceptance + if: always() + run: | + [ -f "gateway-api/test-artefacts/acceptance-tests.xml" ] && echo "exists=true" >> "$GITHUB_OUTPUT" || echo "exists=false" >> "$GITHUB_OUTPUT" + + - name: Publish acceptance test results to summary + if: ${{ always() && steps.check-acceptance.outputs.exists == 'true' }} + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 + with: + paths: gateway-api/test-artefacts/acceptance-tests.xml + + # Cleanup after tests + - name: Remove mTLS temp files + if: github.event.action != 'closed' + run: rm -f /tmp/client1-key.pem /tmp/client1-cert.pem || true + - name: Comment function name on PR if: github.event_name == 'pull_request' && github.event.action != 'closed' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd @@ -368,3 +521,4 @@ jobs: with: image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}} artifact-name: trivy-sbom-${{ steps.meta.outputs.branch_name }} + diff --git a/gateway-api/tests/acceptance/features/hello_world.feature b/gateway-api/tests/acceptance/features/hello_world.feature deleted file mode 100644 index a5375d50..00000000 --- a/gateway-api/tests/acceptance/features/hello_world.feature +++ /dev/null @@ -1,16 +0,0 @@ -Feature: Gateway API Hello World - As an API consumer - I want to interact with the Gateway API - So that I can verify it responds correctly to valid and invalid requests - - Background: The API is running - Given the API is running - - Scenario: Get hello world message - When I send "World" to the endpoint - Then the response status code should be 200 - And the response should contain "Hello, World!" - - Scenario: Accessing a non-existent endpoint returns a 404 - When I send "nonexistent" to the endpoint - Then the response status code should be 404 diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index 3485f224..f87001cf 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -40,6 +40,7 @@ def send_to_nonexistent_endpoint( url=nonexistent_endpoint, data=json.dumps(simple_request_payload), timeout=timedelta(seconds=1).total_seconds(), + cert=client.cert, ) diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index 7fef2c54..826060b9 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -21,6 +21,13 @@ def __init__(self, base_url: str, timeout: timedelta = timedelta(seconds=1)): self.base_url = base_url self._timeout = timeout.total_seconds() + cert = None + cert_path = os.getenv("MTLS_CERT") + key_path = os.getenv("MTLS_KEY") + if cert_path and key_path: + cert = (cert_path, key_path) + self.cert = cert + def send_to_get_structured_record_endpoint( self, payload: str, headers: dict[str, str] | None = None ) -> requests.Response: @@ -40,6 +47,7 @@ def send_to_get_structured_record_endpoint( data=payload, headers=default_headers, timeout=self._timeout, + cert=self.cert, ) def send_health_check(self) -> requests.Response: @@ -49,7 +57,7 @@ def send_health_check(self) -> requests.Response: Response object from the request """ url = f"{self.base_url}/health" - return requests.get(url=url, timeout=self._timeout) + return requests.get(url=url, timeout=self._timeout, cert=self.cert) @pytest.fixture diff --git a/gateway-api/tests/contract/conftest.py b/gateway-api/tests/contract/conftest.py new file mode 100644 index 00000000..49df8670 --- /dev/null +++ b/gateway-api/tests/contract/conftest.py @@ -0,0 +1,94 @@ +import os +import threading +from collections.abc import Generator +from functools import partial +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +import pytest +import requests + + +def get_mtls_cert() -> tuple[str, str] | None: + cert_path = os.getenv("MTLS_CERT") + key_path = os.getenv("MTLS_KEY") + if not cert_path or not key_path: + return None + return (cert_path, key_path) + + +class MtlsProxyHandler(BaseHTTPRequestHandler): + """ + A simple proxy that forwards requests to the target HTTPS URL + attaching the mTLS client certificates. + """ + + def __init__( + self, + target_base: str, + cert: tuple[str, str] | None, + *args: Any, + **kwargs: Any, + ) -> None: + self.target_base = target_base + self.cert = cert + super().__init__(*args, **kwargs) + + def do_proxy(self, method: str) -> None: + if not self.target_base: + self.send_error(500, "Target base URL not set") + return + + url = f"{self.target_base}{self.path}" + content_length_header = self.headers.get("Content-Length") + content_length = int(content_length_header) if content_length_header else 0 + body = self.rfile.read(content_length) if content_length > 0 else None + headers = {k: v for k, v in self.headers.items() if k.lower() != "host"} + + try: + response = requests.request( + method=method, + url=url, + headers=headers, + data=body, + cert=self.cert, + verify=False, + timeout=30, + ) + + self.send_response(response.status_code) + for k, v in response.headers.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(response.content) + + except Exception as e: + self.send_error(500, f"Proxy Error: {str(e)}") + + def do_GET(self) -> None: + self.do_proxy("GET") + + def do_POST(self) -> None: + self.do_proxy("POST") + + def do_PUT(self) -> None: + self.do_proxy("PUT") + + +@pytest.fixture(scope="module") +def mtls_proxy(base_url: str) -> Generator[str, None, None]: + """ + Spins up a local HTTP server in a separate thread. + Returns the URL of this local proxy. + """ + + cert = get_mtls_cert() + handler_factory = partial(MtlsProxyHandler, base_url, cert) + server = HTTPServer(("localhost", 0), handler_factory) + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + yield f"http://localhost:{server.server_port}" + + server.shutdown() diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index cf1998c3..da1078bb 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -14,12 +14,7 @@ class TestConsumerContract: """Consumer contract tests to define expected API behavior.""" def test_get_structured_record(self) -> None: - """Test the consumer's expectation of the get structured record endpoint. - - This test defines the contract: when the consumer requests - POST to the /patient/$gpc.getstructuredrecord endpoint, - a 200 response containing a FHIR Bundle is returned. - """ + """Test the consumer's expectation of the get structured record endpoint.""" pact = Pact(consumer="GatewayAPIConsumer", provider="GatewayAPIProvider") expected_bundle = { @@ -34,6 +29,7 @@ def test_get_structured_record(self) -> None: { "resource": { "resourceType": "Patient", + # The API returns this specific UUID, not the NHS number as ID "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", "meta": { "versionId": "1469448000000", @@ -96,7 +92,6 @@ def test_get_structured_record(self) -> None: # Start the mock server and execute the test with pact.serve() as server: - # Make the actual request to the mock provider response = requests.post( f"{server.url}/patient/$gpc.getstructuredrecord", data=json.dumps( @@ -121,46 +116,28 @@ def test_get_structured_record(self) -> None: timeout=10, ) - # Verify the response matches expectations assert response.status_code == 200 - body = response.json() - assert body["resourceType"] == "Bundle" - assert body["type"] == "collection" - assert len(body["entry"]) == 1 - assert body["entry"][0]["resource"]["resourceType"] == "Patient" - assert ( - body["entry"][0]["resource"]["id"] - == "04603d77-1a4e-4d63-b246-d7504f8bd833" - ) + # Basic assertion to ensure the test itself passes assert ( - body["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" + response.json()["entry"][0]["resource"]["name"][0]["family"] + == "Jackson" ) - # Write the pact file after the test + # Write the pact file pact.write_file("tests/contract/pacts") def test_get_nonexistent_route(self) -> None: - """Test the consumer's expectation when requesting a non-existent route. - - This test defines the contract: when the consumer requests - a route that doesn't exist, they expect a 404 response. - """ + """Test the consumer's expectation when requesting a non-existent route.""" pact = Pact(consumer="GatewayAPIConsumer", provider="GatewayAPIProvider") - # Define the expected interaction ( pact.upon_receiving("a request for a non-existent route") .with_request(method="GET", path="/nonexistent") .will_respond_with(status=404) ) - # Start the mock server and execute the test with pact.serve() as server: - # Make the actual request to the mock provider response = requests.get(f"{server.url}/nonexistent", timeout=10) - - # Verify the response matches expectations assert response.status_code == 404 - # Write the pact file after the test pact.write_file("tests/contract/pacts") diff --git a/gateway-api/tests/contract/test_provider_contract.py b/gateway-api/tests/contract/test_provider_contract.py index 8604d2bf..e03e6f71 100644 --- a/gateway-api/tests/contract/test_provider_contract.py +++ b/gateway-api/tests/contract/test_provider_contract.py @@ -7,28 +7,15 @@ from pact import Verifier -class TestProviderContract: - """Provider contract tests to verify the API implementation.""" +def test_provider_honors_consumer_contract(mtls_proxy: str) -> None: + verifier = Verifier( + name="GatewayAPIProvider", + ) - def test_provider_honors_consumer_contract( - self, base_url: str, hostname: str - ) -> None: - """Verify that the provider satisfies all consumer contracts. + verifier.add_transport(url=mtls_proxy) - This test verifies the Flask API against the pact files - generated by consumer tests. - """ + verifier.add_source( + "tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json" + ) - # Create a verifier for the provider - verifier = Verifier(name="GatewayAPIProvider", host=hostname) - - # Add the transport (how to connect to the provider) - verifier.add_transport(url=base_url) - - # Add the pact file as a source - verifier.add_source( - "tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json" - ) - - # Verify the provider against the pact - verifier.verify() + verifier.verify() diff --git a/gateway-api/tests/schema/test_openapi_schema.py b/gateway-api/tests/schema/test_openapi_schema.py index 407f5de4..0d7c6791 100644 --- a/gateway-api/tests/schema/test_openapi_schema.py +++ b/gateway-api/tests/schema/test_openapi_schema.py @@ -4,6 +4,7 @@ from the OpenAPI specification and validate the API implementation. """ +import os from pathlib import Path import schemathesis @@ -37,10 +38,21 @@ def test_api_schema_compliance(case: Case, base_url: str) -> None: Note: Server error checks are disabled because the API may return 500 errors when testing with randomly generated NHS numbers that don't exist in the PDS. """ - # Call the API and validate the response against the schema - # Exclude not_a_server_error check as 500 responses are expected for - # non-existent patients + + cert = None + cert_path = os.getenv("MTLS_CERT") + key_path = os.getenv("MTLS_KEY") + if cert_path and key_path: + cert = (cert_path, key_path) + + if case.headers is not None: + case.headers["Ods-from"] = "test-ods-code" + case.headers["Ssp-TraceID"] = "test-trace-id" + case.call_and_validate( base_url=base_url, excluded_checks=[schemathesis.checks.not_a_server_error], + cert=cert, + verify=False, + timeout=30, ) From b9445352577887064a790a348d8f977ac40f50b4 Mon Sep 17 00:00:00 2001 From: neil-sproston Date: Fri, 13 Feb 2026 15:06:43 +0000 Subject: [PATCH 2/2] Set terraform version pinning --- .tool-versions | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.tool-versions b/.tool-versions index 253dc21c..7d8d4e9b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,6 +1,6 @@ # This file is for you! Please, updated to the versions agreed by your team. -terraform 1.14.0 +terraform 1.14.5 pre-commit 3.6.0 gitleaks 8.18.4 @@ -15,7 +15,7 @@ gitleaks 8.18.4 # docker/ghcr.io/make-ops-tools/gocloc latest@sha256:6888e62e9ae693c4ebcfed9f1d86c70fd083868acb8815fe44b561b9a73b5032 # SEE: https://github.com/make-ops-tools/gocloc/pkgs/container/gocloc # docker/ghcr.io/nhs-england-tools/github-runner-image 20230909-321fd1e-rt@sha256:ce4fd6035dc450a50d3cbafb4986d60e77cb49a71ab60a053bb1b9518139a646 # SEE: https://github.com/nhs-england-tools/github-runner-image/pkgs/container/github-runner-image # docker/hadolint/hadolint 2.12.0-alpine@sha256:7dba9a9f1a0350f6d021fb2f6f88900998a4fb0aaf8e4330aa8c38544f04db42 # SEE: https://hub.docker.com/r/hadolint/hadolint/tags -# docker/hashicorp/terraform 1.12.2@sha256:b3d13c9037d2bd858fe10060999aa7ca56d30daafe067d7715b29b3d4f5b162f # SEE: https://hub.docker.com/r/hashicorp/terraform/tags +docker/hashicorp/terraform 1.14.5@sha256:96d2bc440714bf2b2f2998ac730fd4612f30746df43fca6f0892b2e2035b11bc # SEE: https://hub.docker.com/r/hashicorp/terraform/tags # docker/koalaman/shellcheck latest@sha256:e40388688bae0fcffdddb7e4dea49b900c18933b452add0930654b2dea3e7d5c # SEE: https://hub.docker.com/r/koalaman/shellcheck/tags # docker/mstruebing/editorconfig-checker 2.7.1@sha256:dd3ca9ea50ef4518efe9be018d669ef9cf937f6bb5cfe2ef84ff2a620b5ddc24 # SEE: https://hub.docker.com/r/mstruebing/editorconfig-checker/tags # docker/sonarsource/sonar-scanner-cli 10.0@sha256:0bc49076468d2955948867620b2d98d67f0d59c0fd4a5ef1f0afc55cf86f2079 # SEE: https://hub.docker.com/r/sonarsource/sonar-scanner-cli/tags