Skip to content

Commit be094bf

Browse files
redo
1 parent 2d3287f commit be094bf

8 files changed

Lines changed: 854 additions & 59 deletions

File tree

.github/workflows/preview-env.yml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -380,13 +380,14 @@ jobs:
380380
- name: Run integration tests against preview
381381
if: github.event.action != 'closed'
382382
env:
383-
BASE_URL: ${{ steps.tf-output.outputs.preview_url }}
384-
MTLS_CERT: /tmp/client1-cert.pem
385-
MTLS_KEY: /tmp/client1-key.pem
386-
STUB_SDS: "true"
387-
STUB_PDS: "true"
388-
STUB_PROVIDER: "true"
389-
run: make test-integration
383+
BASE_URL: "https://internal-dev.api.service.nhs.uk/clinical-data-gateway-api-poc"
384+
PR_NUMBER: ${{ github.event.pull_request.number }}
385+
PROXYGEN_KEY_ID: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }}
386+
PROXYGEN_CLIENT_ID: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }}
387+
PROXYGEN_KEY_SECRET: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }}
388+
run: |
389+
touch .env.remote
390+
make test-remote
390391
391392
- name: Upload integration test results
392393
if: always()

gateway-api/poetry.lock

Lines changed: 597 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gateway-api/pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,16 @@ dev = [
5757
"types-requests (>=2.32.4.20250913,<3.0.0.0)",
5858
"types-pyyaml (>=6.0.12.20250915,<7.0.0.0)",
5959
"pytest-mock (>=3.15.1,<4.0.0)",
60+
"pytest-nhsd-apim (>=6.0.6,<7.0.0)",
6061
]
6162

6263
[tool.mypy]
6364
strict = true
65+
66+
[tool.pytest.ini_options]
67+
bdd_features_base_dir = "tests/acceptance/features"
68+
markers = [
69+
"remote_only: test only runs in remote environment (skipped when --env=local)",
70+
"status_auth_headers",
71+
"status_merged_auth_headers",
72+
]

gateway-api/tests/conftest.py

Lines changed: 146 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,63 @@
22

33
import os
44
from datetime import timedelta
5-
from typing import cast
5+
from typing import Any, Protocol, cast
66

77
import pytest
88
import requests
99
from dotenv import find_dotenv, load_dotenv
1010
from fhir.parameters import Parameters
1111

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

1615

17-
class Client:
18-
"""A simple HTTP client for testing purposes."""
16+
class Client(Protocol):
17+
"""Protocol defining the interface for HTTP clients."""
1918

20-
def __init__(self, base_url: str, timeout: timedelta = timedelta(seconds=1)):
21-
self.base_url = base_url
22-
self._timeout = timeout.total_seconds()
23-
24-
cert = None
25-
cert_path = os.getenv("MTLS_CERT")
26-
key_path = os.getenv("MTLS_KEY")
27-
if cert_path and key_path:
28-
cert = (cert_path, key_path)
29-
self.cert = cert
19+
base_url: str
20+
cert: tuple[str, str] | None
3021

3122
def send_to_get_structured_record_endpoint(
3223
self, payload: str, headers: dict[str, str] | None = None
3324
) -> requests.Response:
3425
"""
3526
Send a request to the get_structured_record endpoint with the given NHS number.
3627
"""
28+
...
29+
30+
def send_health_check(self) -> requests.Response:
31+
"""
32+
Send a health check request to the API.
33+
"""
34+
...
35+
36+
37+
class LocalClient:
38+
"""HTTP client that sends requests directly to the API (no proxy auth)."""
39+
40+
def __init__(
41+
self,
42+
base_url: str,
43+
cert: tuple[str, str] | None = None,
44+
timeout: timedelta = timedelta(seconds=1),
45+
):
46+
self.base_url = base_url
47+
self.cert = cert
48+
self._timeout = timeout.total_seconds()
49+
50+
def send_to_get_structured_record_endpoint(
51+
self, payload: str, headers: dict[str, str] | None = None
52+
) -> requests.Response:
3753
url = f"{self.base_url}/patient/$gpc.getstructuredrecord"
3854
default_headers = {
3955
"Content-Type": "application/fhir+json",
40-
"Ods-from": "A12345",
56+
"Ods-from": "CONSUMER",
4157
"Ssp-TraceID": "test-trace-id",
4258
}
4359
if headers:
4460
default_headers.update(headers)
61+
4562
return requests.post(
4663
url=url,
4764
data=payload,
@@ -51,24 +68,62 @@ def send_to_get_structured_record_endpoint(
5168
)
5269

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

6274

75+
class RemoteClient:
76+
"""HTTP client for remote testing via the APIM proxy."""
77+
78+
def __init__(
79+
self,
80+
api_url: str,
81+
auth_headers: dict[str, str],
82+
cert: tuple[str, str] | None = None,
83+
timeout: timedelta = timedelta(seconds=5),
84+
):
85+
self.base_url = api_url
86+
self.cert = cert
87+
self._auth_headers = auth_headers
88+
self._timeout = timeout.total_seconds()
89+
90+
def send_to_get_structured_record_endpoint(
91+
self, payload: str, headers: dict[str, str] | None = None
92+
) -> requests.Response:
93+
url = f"{self.base_url}/patient/$gpc.getstructuredrecord"
94+
95+
default_headers = self._auth_headers | {
96+
"Content-Type": "application/fhir+json",
97+
"Ods-from": "CONSUMER",
98+
"Ssp-TraceID": "test-trace-id",
99+
}
100+
if headers:
101+
default_headers.update(headers)
102+
103+
return requests.post(
104+
url=url,
105+
data=payload,
106+
headers=default_headers,
107+
timeout=self._timeout,
108+
cert=self.cert,
109+
)
110+
111+
def send_health_check(self) -> requests.Response:
112+
url = f"{self.base_url}/health"
113+
return requests.get(
114+
url=url, headers=self._auth_headers, timeout=self._timeout, cert=self.cert
115+
)
116+
117+
63118
@pytest.fixture(scope="session")
64119
def mtls_cert() -> tuple[str, str] | None:
65-
"""
66-
Provide mTLS certificate paths.
67-
"""
120+
"""Returns the mTLS certificate and key paths if provided in the environment."""
68121
cert_path = os.getenv("MTLS_CERT")
69122
key_path = os.getenv("MTLS_KEY")
123+
70124
if cert_path and key_path:
71125
return (cert_path, key_path)
126+
72127
return None
73128

74129

@@ -89,18 +144,46 @@ def simple_request_payload() -> Parameters:
89144

90145

91146
@pytest.fixture
92-
def happy_path_headers() -> dict[str, str]:
93-
return {
94-
"Content-Type": "application/fhir+json",
95-
"Ods-from": "A12345",
96-
"Ssp-TraceID": "test-trace-id",
97-
}
147+
def get_headers(request: pytest.FixtureRequest) -> Any:
148+
"""Return merged auth headers for remote tests, or empty dict for local."""
149+
env = request.config.getoption("--env")
150+
if env == "remote":
151+
apikey_headers = request.getfixturevalue("status_endpoint_auth_headers")
152+
nhsd_headers = request.getfixturevalue("nhsd_apim_auth_headers")
153+
headers = nhsd_headers | apikey_headers
154+
return headers
98155

156+
return {}
99157

100-
@pytest.fixture(scope="module")
101-
def client(base_url: str) -> Client:
102-
"""Create a test client for the application."""
103-
return Client(base_url=base_url)
158+
159+
@pytest.fixture
160+
def client(
161+
request: pytest.FixtureRequest,
162+
base_url: str,
163+
mtls_cert: tuple[str, str] | None,
164+
) -> Client:
165+
"""Create the appropriate HTTP client."""
166+
env = os.getenv("ENV") or request.config.getoption("--env")
167+
168+
if env == "local":
169+
return LocalClient(base_url=base_url, cert=mtls_cert)
170+
elif env == "remote":
171+
proxy_url = request.getfixturevalue("nhsd_apim_proxy_url")
172+
173+
apikey_headers = request.getfixturevalue("status_endpoint_auth_headers")
174+
token = os.getenv("APIGEE_ACCESS_TOKEN")
175+
176+
if token:
177+
auth_headers = {"Authorization": f"Bearer {token}", **apikey_headers}
178+
else:
179+
nhsd_headers = request.getfixturevalue("nhsd_apim_auth_headers")
180+
auth_headers = nhsd_headers | apikey_headers
181+
182+
return RemoteClient(
183+
api_url=proxy_url, auth_headers=auth_headers, cert=mtls_cert
184+
)
185+
else:
186+
raise ValueError(f"Unknown env: {env}")
104187

105188

106189
@pytest.fixture(scope="module")
@@ -123,3 +206,32 @@ def _fetch_env_variable[T](
123206
if not value:
124207
raise ValueError(f"{name} environment variable is not set.")
125208
return cast("T", value)
209+
210+
211+
def pytest_addoption(parser: pytest.Parser) -> None:
212+
parser.addoption(
213+
"--env",
214+
action="store",
215+
default="local",
216+
help="Environment to run tests against",
217+
)
218+
219+
220+
def pytest_collection_modifyitems(
221+
config: pytest.Config, items: list[pytest.Item]
222+
) -> None:
223+
env = os.getenv("ENV") or config.getoption("--env")
224+
225+
if env == "local":
226+
skip_remote = pytest.mark.skip(reason="Test only runs in remote environment")
227+
for item in items:
228+
if item.get_closest_marker("remote_only"):
229+
item.add_marker(skip_remote)
230+
231+
if env == "remote":
232+
for item in items:
233+
item.add_marker(
234+
pytest.mark.nhsd_apim_authorization(
235+
access="application", level="level3"
236+
)
237+
)

gateway-api/tests/contract/test_provider_contract.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,21 @@
44
satisfies the contracts defined by consumers.
55
"""
66

7+
from typing import Any
8+
79
from pact import Verifier
810

911

10-
def test_provider_honors_consumer_contract(mtls_proxy: str) -> None:
12+
def test_provider_honors_consumer_contract(mtls_proxy: str, get_headers: Any) -> None:
1113
verifier = Verifier(
1214
name="GatewayAPIProvider",
1315
)
1416

1517
verifier.add_transport(url=mtls_proxy)
1618

19+
# So the Verifier can authenticate with the APIM proxy
20+
verifier.add_custom_headers(get_headers)
21+
1722
verifier.add_source(
1823
"tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json"
1924
)

scripts/get_apigee_token.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Generates an APIGEE access token for remote test runs.
5+
# Prints only the token to stdout; all diagnostics go to stderr.
6+
#
7+
# Prerequisites:
8+
# - proxygen CLI installed and configured (credentials in ~/.proxygen/credentials.yaml)
9+
# - jq installed
10+
# - Valid proxygen key (PROXYGEN_KEY_ID / PROXYGEN_CLIENT_ID env vars or config)
11+
#
12+
# The token is valid for ~24 hours and is a secret — do not log it.
13+
14+
echo "Generating APIGEE access token via proxygen..." >&2
15+
16+
TOKEN=$(proxygen pytest-nhsd-apim get-token | jq -r '.pytest_nhsd_apim_token')
17+
18+
if [[ -z "${TOKEN}" || "${TOKEN}" == "null" ]]; then
19+
echo "ERROR: Failed to obtain a valid token." >&2
20+
exit 1
21+
fi
22+
23+
echo "Token obtained successfully." >&2
24+
echo "${TOKEN}"

scripts/tests/run-test.sh

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,27 @@ mkdir -p test-artefacts
3535

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

38-
# Set coverage path based on test type
3938
if [[ "$TEST_TYPE" = "unit" ]]; then
4039
COV_PATH="."
40+
41+
poetry run pytest ${TEST_PATH} -v \
42+
--cov=${COV_PATH} \
43+
--cov-report=html:test-artefacts/coverage-html \
44+
--cov-report=term \
45+
--junit-xml="test-artefacts/${TEST_TYPE}-tests.xml" \
46+
--html="test-artefacts/${TEST_TYPE}-tests.html" --self-contained-html
4147
else
4248
COV_PATH="src/gateway_api"
49+
TEST_ENV="${ENV:-local}"
50+
51+
poetry run pytest ${TEST_PATH} -v \
52+
--env="${TEST_ENV}" \
53+
--cov=${COV_PATH} \
54+
--cov-report=html:test-artefacts/coverage-html \
55+
--cov-report=term \
56+
--junit-xml="test-artefacts/${TEST_TYPE}-tests.xml" \
57+
--html="test-artefacts/${TEST_TYPE}-tests.html" --self-contained-html
4358
fi
4459

45-
# Note: TEST_PATH is intentionally unquoted to allow glob expansion for unit tests
46-
poetry run pytest ${TEST_PATH} -v \
47-
--cov=${COV_PATH} \
48-
--cov-report=html:test-artefacts/coverage-html \
49-
--cov-report=term \
50-
--junit-xml="test-artefacts/${TEST_TYPE}-tests.xml" \
51-
--html="test-artefacts/${TEST_TYPE}-tests.html" --self-contained-html
52-
5360
# Save coverage data file for merging
5461
mv .coverage "test-artefacts/coverage.${TEST_TYPE}"

0 commit comments

Comments
 (0)