Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/workflows/preview-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -374,16 +374,19 @@ jobs:
paths: gateway-api/test-artefacts/schema-tests.xml

# INTEGRATION TESTS
- name: Run integration tests against preview
- name: Run integration tests against preview using remote APIM proxy
if: github.event.action != 'closed'
env:
BASE_URL: ${{ steps.tf-output.outputs.preview_url }}
BASE_URL: "https://internal-dev.api.service.nhs.uk/clinical-data-gateway-api-poc"
PR_NUMBER: ${{ github.event.pull_request.number }}
PROXYGEN_KEY_ID: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }}
PROXYGEN_CLIENT_ID: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }}
PROXYGEN_KEY_SECRET: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }}
MTLS_CERT: /tmp/client1-cert.pem
MTLS_KEY: /tmp/client1-key.pem
STUB_SDS: "true"
STUB_PDS: "true"
STUB_PROVIDER: "true"
run: make test-integration
run: |
touch .env.remote
make test-remote

- name: Upload integration test results
if: always()
Expand Down
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ build-gateway-api: dependencies
@poetry run mypy --no-namespace-packages .
@echo "Packaging dependencies..."
@poetry build --format=wheel
@pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_2_x86_64 --only-binary=:all:
# Copy main file separately as it is not included within the package.
@poetry run pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_2_x86_64 --only-binary=:all: # Copy main file separately as it is not included within the package.
@rm -rf ../infrastructure/images/gateway-api/resources/build/
@mkdir ../infrastructure/images/gateway-api/resources/build/
@cp -r ./target/gateway-api ../infrastructure/images/gateway-api/resources/build/
Expand Down
9 changes: 9 additions & 0 deletions gateway-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,16 @@ dev = [
"types-requests (>=2.32.4.20250913,<3.0.0.0)",
"types-pyyaml (>=6.0.12.20250915,<7.0.0.0)",
"pytest-mock (>=3.15.1,<4.0.0)",
"pytest-nhsd-apim (>=6.0.6,<7.0.0)",
]

[tool.mypy]
strict = true

[tool.pytest.ini_options]
bdd_features_base_dir = "tests/acceptance/features"
markers = [
"remote_only: test only runs in remote environment (skipped when --env=local)",
"status_auth_headers",
"status_merged_auth_headers",
]
172 changes: 139 additions & 33 deletions gateway-api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import os
from datetime import timedelta
from typing import cast
from typing import Protocol, cast

import pytest
import requests
Expand All @@ -11,37 +11,55 @@

# Load environment variables from .env file in the workspace root
# find_dotenv searches upward from current directory for .env file
load_dotenv(find_dotenv(usecwd=True))
load_dotenv(find_dotenv())


class Client:
"""A simple HTTP client for testing purposes."""
class Client(Protocol):
"""Protocol defining the interface for HTTP clients."""

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
base_url: str
cert: tuple[str, str] | None

def send_to_get_structured_record_endpoint(
self, payload: str, headers: dict[str, str] | None = None
) -> requests.Response:
"""
Send a request to the get_structured_record endpoint with the given NHS number.
"""
...

def send_health_check(self) -> requests.Response:
"""
Send a health check request to the API.
"""
...


class LocalClient:
"""HTTP client that sends requests directly to the API (no proxy auth)."""

def __init__(
self,
base_url: str,
cert: tuple[str, str] | None = None,
timeout: timedelta = timedelta(seconds=1),
):
self.base_url = base_url
self.cert = cert
self._timeout = timeout.total_seconds()

def send_to_get_structured_record_endpoint(
self, payload: str, headers: dict[str, str] | None = None
) -> requests.Response:
url = f"{self.base_url}/patient/$gpc.getstructuredrecord"
default_headers = {
"Content-Type": "application/fhir+json",
"Ods-from": "A12345",
"Ods-from": "CONSUMER",
"Ssp-TraceID": "test-trace-id",
}
if headers:
default_headers.update(headers)

return requests.post(
url=url,
data=payload,
Expand All @@ -51,24 +69,62 @@ def send_to_get_structured_record_endpoint(
)

def send_health_check(self) -> requests.Response:
"""
Send a health check request to the API.
Returns:
Response object from the request
"""
url = f"{self.base_url}/health"
return requests.get(url=url, timeout=self._timeout, cert=self.cert)


class RemoteClient:
"""HTTP client for remote testing via the APIM proxy."""

def __init__(
self,
api_url: str,
auth_headers: dict[str, str],
cert: tuple[str, str] | None = None,
timeout: timedelta = timedelta(seconds=5),
):
self.base_url = api_url
self.cert = cert
self._auth_headers = auth_headers
self._timeout = timeout.total_seconds()

def send_to_get_structured_record_endpoint(
self, payload: str, headers: dict[str, str] | None = None
) -> requests.Response:
url = f"{self.base_url}/patient/$gpc.getstructuredrecord"

default_headers = self._auth_headers | {
"Content-Type": "application/fhir+json",
"Ods-from": "CONSUMER",
"Ssp-TraceID": "test-trace-id",
}
if headers:
default_headers.update(headers)

return requests.post(
url=url,
data=payload,
headers=default_headers,
timeout=self._timeout,
cert=self.cert,
)

def send_health_check(self) -> requests.Response:
url = f"{self.base_url}/health"
return requests.get(
url=url, headers=self._auth_headers, timeout=self._timeout, cert=self.cert
)


@pytest.fixture(scope="session")
def mtls_cert() -> tuple[str, str] | None:
"""
Provide mTLS certificate paths.
"""
"""Returns the mTLS certificate and key paths if provided in the environment."""
cert_path = os.getenv("MTLS_CERT")
key_path = os.getenv("MTLS_KEY")

if cert_path and key_path:
return (cert_path, key_path)

return None


Expand All @@ -89,18 +145,41 @@ def simple_request_payload() -> Parameters:


@pytest.fixture
def happy_path_headers() -> dict[str, str]:
return {
"Content-Type": "application/fhir+json",
"Ods-from": "A12345",
"Ssp-TraceID": "test-trace-id",
}
def get_headers(request: pytest.FixtureRequest) -> dict[str, str]:
"""Return merged auth headers for remote tests, or empty dict for local."""
env = os.getenv("ENV", "local")
if env == "remote":
headers = request.getfixturevalue(
"nhsd_apim_auth_headers"
) | request.getfixturevalue("status_endpoint_auth_headers")
return cast("dict[str, str]", headers)

return {}

@pytest.fixture(scope="module")
def client(base_url: str) -> Client:
"""Create a test client for the application."""
return Client(base_url=base_url)

@pytest.fixture
def client(
request: pytest.FixtureRequest,
base_url: str,
mtls_cert: tuple[str, str] | None,
) -> Client:
"""Create the appropriate HTTP client."""
env = os.getenv("ENV", "local")

if env == "local":
return LocalClient(base_url=base_url, cert=mtls_cert)
elif env == "remote":
proxy_url = request.getfixturevalue("nhsd_apim_proxy_url")

auth_headers = request.getfixturevalue(
"nhsd_apim_auth_headers"
) | request.getfixturevalue("status_endpoint_auth_headers")

return RemoteClient(
api_url=proxy_url, auth_headers=auth_headers, cert=mtls_cert
)
else:
raise ValueError(f"Unknown env: {env}")


@pytest.fixture(scope="module")
Expand All @@ -123,3 +202,30 @@ def _fetch_env_variable[T](
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",
action="store",
default="local",
help="Environment to run tests against",
)


def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
env = os.getenv("ENV", "local")

if env == "local":
skip_remote = pytest.mark.skip(reason="Test only runs in remote environment")
for item in items:
if item.get_closest_marker("remote_only"):
item.add_marker(skip_remote)

if env == "remote":
for item in items:
item.add_marker(
pytest.mark.nhsd_apim_authorization(
access="application", level="level3"
)
)
7 changes: 6 additions & 1 deletion gateway-api/tests/contract/test_provider_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@
satisfies the contracts defined by consumers.
"""

from typing import Any

from pact import Verifier


def test_provider_honors_consumer_contract(mtls_proxy: str) -> None:
def test_provider_honors_consumer_contract(mtls_proxy: str, get_headers: Any) -> None:
verifier = Verifier(
name="GatewayAPIProvider",
)

verifier.add_transport(url=mtls_proxy)

# So the Verifier can authenticate with the APIM proxy
verifier.add_custom_headers(get_headers)

verifier.add_source(
"tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json"
)
Expand Down
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
[pytest]
bdd_features_base_dir = gateway-api/tests/acceptance/features
markers = [
"remote_only: test only runs in remote environment (skipped when --env=local)",
"status_auth_headers",
"status_merged_auth_headers",
]
24 changes: 24 additions & 0 deletions scripts/get_apigee_token.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail

# Generates an APIGEE access token for remote test runs.
# Prints only the token to stdout; all diagnostics go to stderr.
#
# Prerequisites:
# - proxygen CLI installed and configured (credentials in ~/.proxygen/credentials.yaml)
# - jq installed
# - Valid proxygen key (PROXYGEN_KEY_ID / PROXYGEN_CLIENT_ID env vars or config)
#
# The token is valid for ~24 hours and is a secret — do not log it.

echo "Generating APIGEE access token via proxygen..." >&2

TOKEN=$(proxygen pytest-nhsd-apim get-token | jq -r '.pytest_nhsd_apim_token')

if [[ -z "${TOKEN}" || "${TOKEN}" == "null" ]]; then
echo "ERROR: Failed to obtain a valid token." >&2
exit 1
fi

echo "Token obtained successfully." >&2
echo "${TOKEN}"
48 changes: 48 additions & 0 deletions scripts/tests/test.mk
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,51 @@ ${VERBOSE}.SILENT: \
test-unit \
test-acceptance \
test-schema\

# ---------------------------------------------------------------------------
# Local vs remote environment helpers
# ---------------------------------------------------------------------------
# Usage:
# make test-local - activate .env.local, run tests against local app
# make test-remote - activate .env.remote, obtain an APIGEE token, run
# tests against remote app and APIM proxy
#
# .env.local - local env config (ENV=local, BASE_URL, HOST)
# .env.remote - remote env config (ENV=remote, BASE_URL, HOST, PR_NUMBER, PROXYGEN_API_NAME)
# .env - active env, overwritten by env-local/env-remote
#
# The .env file is deterministically overwritten each time you switch
# environments so the test runner always sees a consistent configuration.
# ===========================================================================

.PHONY: env-local env-remote test-local test-remote

env-local:
@if [ ! -f .env.local ]; then \
echo "ERROR: .env.local not found. Needs creating." >&2; \
exit 1; \
fi
cp -f .env.local .env
@echo "Activated local environment: .env.local -> .env (ENV=local)"

env-remote:
@if [ ! -f .env.remote ]; then \
echo "ERROR: .env.remote not found. Needs creating." >&2; \
exit 1; \
fi
cp -f .env.remote .env
@echo "Activated remote environment: .env.remote -> .env (ENV=remote)"

# Run tests against local lambda
test-local: env-local
@set -a && source .env && set +a && \
$(MAKE) test

# Run tests against remote lambda, exporting APIGEE_ACCESS_TOKEN only
test-remote: env-remote
@echo "Obtaining APIGEE access token..."
@set -a && source .env && set +a && \
APIGEE_ACCESS_TOKEN="$$(./scripts/get_apigee_token.sh)" && \
BASE_URL="$${BASE_URL}-pr-$${PR_NUMBER}" && \
export APIGEE_ACCESS_TOKEN BASE_URL && \
$(MAKE) test
Loading