Skip to content
Draft

redo #107

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: 8 additions & 7 deletions .github/workflows/preview-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -380,13 +380,14 @@ jobs:
- 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
STUB_SDS: "true"
STUB_PDS: "true"
STUB_PROVIDER: "true"
run: make test-integration
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 }}
run: |
touch .env.remote
make test-remote

- name: Upload integration test results
if: always()
Expand Down
603 changes: 596 additions & 7 deletions gateway-api/poetry.lock

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions gateway-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,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",
]
180 changes: 146 additions & 34 deletions gateway-api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,63 @@

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

import pytest
import requests
from dotenv import find_dotenv, load_dotenv
from fhir.parameters import Parameters

# 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 +68,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": "A12345",
"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 +144,46 @@ 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) -> Any:
"""Return merged auth headers for remote tests, or empty dict for local."""
env = request.config.getoption("--env")
if env == "remote":
apikey_headers = request.getfixturevalue("status_endpoint_auth_headers")
nhsd_headers = request.getfixturevalue("nhsd_apim_auth_headers")
headers = nhsd_headers | apikey_headers
return 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") or request.config.getoption("--env")

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

apikey_headers = request.getfixturevalue("status_endpoint_auth_headers")
token = os.getenv("APIGEE_ACCESS_TOKEN")

if token:
auth_headers = {"Authorization": f"Bearer {token}", **apikey_headers}
else:
nhsd_headers = request.getfixturevalue("nhsd_apim_auth_headers")
auth_headers = nhsd_headers | apikey_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 +206,32 @@ 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(
config: pytest.Config, items: list[pytest.Item]
) -> None:
env = os.getenv("ENV") or config.getoption("--env")

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
2 changes: 0 additions & 2 deletions pytest.ini

This file was deleted.

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}"
25 changes: 16 additions & 9 deletions scripts/tests/run-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,27 @@ mkdir -p test-artefacts

echo "Running ${TEST_TYPE} tests..."

# Set coverage path based on test type
if [[ "$TEST_TYPE" = "unit" ]]; then
COV_PATH="."

poetry run pytest ${TEST_PATH} -v \
--cov=${COV_PATH} \
--cov-report=html:test-artefacts/coverage-html \
--cov-report=term \
--junit-xml="test-artefacts/${TEST_TYPE}-tests.xml" \
--html="test-artefacts/${TEST_TYPE}-tests.html" --self-contained-html
else
COV_PATH="src/gateway_api"
TEST_ENV="${ENV:-local}"

poetry run pytest ${TEST_PATH} -v \
--env="${TEST_ENV}" \
--cov=${COV_PATH} \
--cov-report=html:test-artefacts/coverage-html \
--cov-report=term \
--junit-xml="test-artefacts/${TEST_TYPE}-tests.xml" \
--html="test-artefacts/${TEST_TYPE}-tests.html" --self-contained-html
fi

# Note: TEST_PATH is intentionally unquoted to allow glob expansion for unit tests
poetry run pytest ${TEST_PATH} -v \
--cov=${COV_PATH} \
--cov-report=html:test-artefacts/coverage-html \
--cov-report=term \
--junit-xml="test-artefacts/${TEST_TYPE}-tests.xml" \
--html="test-artefacts/${TEST_TYPE}-tests.html" --self-contained-html

# Save coverage data file for merging
mv .coverage "test-artefacts/coverage.${TEST_TYPE}"
Loading
Loading