Skip to content
Closed
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
164 changes: 159 additions & 5 deletions .github/workflows/preview-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 \
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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 }}

4 changes: 2 additions & 2 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
16 changes: 0 additions & 16 deletions gateway-api/tests/acceptance/features/hello_world.feature

This file was deleted.

1 change: 1 addition & 0 deletions gateway-api/tests/acceptance/steps/happy_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
10 changes: 9 additions & 1 deletion gateway-api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down
94 changes: 94 additions & 0 deletions gateway-api/tests/contract/conftest.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading