From fdd6bbc05066b0c03322d31bd2f4f232450ba882 Mon Sep 17 00:00:00 2001 From: russellpollock Date: Thu, 5 Feb 2026 12:07:36 +0000 Subject: [PATCH] [GPCAPIM-260]-[Steel Thread integration testing]-[RP] --- .github/workflows/cicd-1-pull-request.yaml | 23 +- .github/workflows/preview-env.yml | 163 +++++++++++- .github/workflows/stage-2-test.yaml | 236 ------------------ .github/workflows/stage-4-acceptance.yaml | 126 ---------- .../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 +- 11 files changed, 294 insertions(+), 461 deletions(-) delete mode 100644 .github/workflows/stage-2-test.yaml delete mode 100644 .github/workflows/stage-4-acceptance.yaml 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/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index e5c39b6e..b0117b1b 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -83,17 +83,9 @@ jobs: IDP_AWS_REPORT_UPLOAD_REGION: ${{ secrets.IDP_AWS_REPORT_UPLOAD_REGION }} IDP_AWS_REPORT_UPLOAD_ROLE_NAME: ${{ secrets.IDP_AWS_REPORT_UPLOAD_ROLE_NAME }} IDP_AWS_REPORT_UPLOAD_BUCKET_ENDPOINT: ${{ secrets.IDP_AWS_REPORT_UPLOAD_BUCKET_ENDPOINT }} - test-stage: # Recommended maximum execution time is 5 minutes - name: "Test stage" - needs: [metadata, commit-stage] - uses: ./.github/workflows/stage-2-test.yaml - with: - python_version: "${{ needs.metadata.outputs.python_version }}" - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} build-stage: # Recommended maximum execution time is 3 minutes name: "Build stage" - needs: [metadata, test-stage] + needs: [metadata] uses: ./.github/workflows/stage-3-build.yaml if: needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) with: @@ -104,16 +96,3 @@ jobs: python_version: "${{ needs.metadata.outputs.python_version }}" terraform_version: "${{ needs.metadata.outputs.terraform_version }}" version: "${{ needs.metadata.outputs.version }}" - acceptance-stage: # Recommended maximum execution time is 10 minutes - name: "Acceptance stage" - needs: [metadata, build-stage] - uses: ./.github/workflows/stage-4-acceptance.yaml - if: needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) - with: - build_datetime: "${{ needs.metadata.outputs.build_datetime }}" - build_timestamp: "${{ needs.metadata.outputs.build_timestamp }}" - build_epoch: "${{ needs.metadata.outputs.build_epoch }}" - nodejs_version: "${{ needs.metadata.outputs.nodejs_version }}" - python_version: "${{ needs.metadata.outputs.python_version }}" - terraform_version: "${{ needs.metadata.outputs.terraform_version }}" - version: "${{ needs.metadata.outputs.version }}" diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 6cb7147e..6b1977ec 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 diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml deleted file mode 100644 index 32a5fd2b..00000000 --- a/.github/workflows/stage-2-test.yaml +++ /dev/null @@ -1,236 +0,0 @@ -name: "Test stage" - -env: - BASE_URL: "http://localhost:5000" - HOST: "localhost" - -on: - workflow_call: - inputs: - python_version: - description: "Python version, set by the CI/CD pipeline workflow" - required: true - type: string - secrets: - SONAR_TOKEN: - description: "SonarCloud token for authentication" - required: true - -jobs: - create-coverage-name: - name: "Create coverage artefact name" - runs-on: ubuntu-latest - outputs: - coverage-name: ${{ steps.create_name.outputs.artefact-name }} - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - id: create_name - name: "Generate unique coverage artefact name" - uses: ./.github/actions/create-artefact-name - with: - prefix: coverage - - test-unit: - name: "Unit tests" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Run unit test suite" - 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: "Publish unit test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: gateway-api/test-artefacts/unit-tests.xml - - test-contract: - name: "Contract tests" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start app" - uses: ./.github/actions/start-app - with: - python-version: ${{ inputs.python_version }} - - name: "Run contract tests" - 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: "Publish contract test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: gateway-api/test-artefacts/contract-tests.xml - - test-schema: - name: "Schema validation tests" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start app" - uses: ./.github/actions/start-app - with: - python-version: ${{ inputs.python_version }} - - name: "Run schema validation tests" - 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: "Publish schema test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: gateway-api/test-artefacts/schema-tests.xml - - test-integration: - name: "Integration tests" - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start app" - uses: ./.github/actions/start-app - with: - python-version: ${{ inputs.python_version }} - - name: "Run integration test" - 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: "Publish integration test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: gateway-api/test-artefacts/integration-tests.xml - - test-acceptance: - name: "Acceptance tests" - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start app" - uses: ./.github/actions/start-app - with: - python-version: ${{ inputs.python_version }} - max-seconds: 90 - - name: "Run acceptance test" - 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: "Publish acceptance test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: gateway-api/test-artefacts/acceptance-tests.xml - - merge-test-coverage: - name: "Merge test coverage" - needs: [create-coverage-name, test-unit, test-contract, test-schema, test-integration, test-acceptance] - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Download all test coverage artefacts" - uses: actions/download-artifact@v6 - with: - path: gateway-api/test-artefacts/ - merge-multiple: false - - name: "Merge coverage data" - run: make test-coverage - - name: "Rename coverage XML with unique name" - run: | - cd gateway-api/test-artefacts - mv coverage-merged.xml ${{ needs.create-coverage-name.outputs.coverage-name }}.xml - - name: "Upload combined coverage report" - if: always() - uses: actions/upload-artifact@v5 - with: - name: ${{ needs.create-coverage-name.outputs.coverage-name }} - path: gateway-api/test-artefacts - retention-days: 30 - - sonarcloud-analysis: - name: "SonarCloud Analysis" - needs: [create-coverage-name, merge-test-coverage] - if: github.actor != 'dependabot[bot]' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - with: - fetch-depth: 0 # Full history is needed for better analysis - - name: "Download merged coverage report" - uses: actions/download-artifact@v6 - with: - name: ${{ needs.create-coverage-name.outputs.coverage-name }} - path: coverage-reports/ - - name: "SonarCloud Scan" - uses: SonarSource/sonarqube-scan-action@a31c9398be7ace6bbfaf30c0bd5d415f843d45e9 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - args: > - -Dsonar.organization=${{ vars.SONAR_ORGANISATION_KEY }} - -Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }} - -Dsonar.python.coverage.reportPaths=coverage-reports/${{ needs.create-coverage-name.outputs.coverage-name }}.xml diff --git a/.github/workflows/stage-4-acceptance.yaml b/.github/workflows/stage-4-acceptance.yaml deleted file mode 100644 index b9d1a157..00000000 --- a/.github/workflows/stage-4-acceptance.yaml +++ /dev/null @@ -1,126 +0,0 @@ -name: "Acceptance stage" - -on: - workflow_call: - inputs: - build_datetime: - description: "Build datetime, set by the CI/CD pipeline workflow" - required: true - type: string - build_timestamp: - description: "Build timestamp, set by the CI/CD pipeline workflow" - required: true - type: string - build_epoch: - description: "Build epoch, set by the CI/CD pipeline workflow" - required: true - type: string - nodejs_version: - description: "Node.js version, set by the CI/CD pipeline workflow" - required: true - type: string - python_version: - description: "Python version, set by the CI/CD pipeline workflow" - required: true - type: string - terraform_version: - description: "Terraform version, set by the CI/CD pipeline workflow" - required: true - type: string - version: - description: "Version of the software, set by the CI/CD pipeline workflow" - required: true - type: string - -jobs: - environment-set-up: - name: "Environment set up" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Create infractructure" - run: | - echo "Creating infractructure..." - - name: "Update database" - run: | - echo "Updating database..." - - name: "Deploy application" - run: | - echo "Deploying application..." - test-security: - name: "Security test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Run security test" - run: | - make test-security - - name: "Save result" - run: | - echo "Nothing to save" - test-ui: - name: "UI test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Run UI test" - run: | - make test-ui - - name: "Save result" - run: | - echo "Nothing to save" - test-ui-performance: - name: "UI performance test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Run UI performance test" - run: | - make test-ui-performance - - name: "Save result" - run: | - echo "Nothing to save" - - test-load: - name: "Load test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Run load tests" - run: | - make test-load - - name: "Save result" - run: | - echo "Nothing to save" - environment-tear-down: - name: "Environment tear down" - runs-on: ubuntu-latest - needs: - [ - test-load, - test-security, - test-ui-performance, - test-ui, - ] - if: always() - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Tear down environment" - run: | - echo "Tearing down environment..." 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, )