From 73b71cfe4654e796975a361b4509558b8615f9f1 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Thu, 2 Apr 2026 16:28:40 +0100 Subject: [PATCH 1/2] Re-enable end-to-end tests, sourcing status endpoint from github secret, rather than fetching from APIM --- .env.template | 1 + .../acceptance-tests-component/action.yml | 38 +++++++++ .../actions/acceptance-tests-e2e/action.yml | 79 ++++++++++++++++++ .github/actions/acceptance-tests/action.yml | 33 +++----- .github/actions/e2e-tests/action.yml | 22 ----- .github/actions/test-types.json | 1 + .../dispatch_internal_repo_workflow.sh | 20 ++++- .github/workflows/stage-3-build.yaml | 37 +++++++-- .github/workflows/stage-4-acceptance.yaml | 20 +---- .gitleaksignore | 6 ++ Makefile | 1 + tests/constants/api-constants.ts | 2 +- tests/e2e-tests/README.md | 30 +++---- .../api/data/test_get_letter_data.py | 18 ++-- .../api/headers/test_x_request_id.py | 13 +-- tests/e2e-tests/api/letters/conftest.py | 14 ++++ .../api/letters/test_get_letter_status.py | 14 ++-- .../api/letters/test_get_list_of_letters.py | 14 ++-- .../letters/test_multiple_letter_status.py | 40 +++------ .../api/letters/test_update_letter_status.py | 33 ++++---- tests/e2e-tests/api/test_endpoint.py | 21 ++--- tests/e2e-tests/lib/authentication.py | 15 +++- tests/e2e-tests/lib/fixtures.py | 20 +++-- tests/e2e-tests/lib/generators.py | 6 +- tests/e2e-tests/lib/letters.py | 82 +++++++++++++++++++ tests/e2e-tests/lib/secret.py | 3 +- 26 files changed, 398 insertions(+), 185 deletions(-) create mode 100644 .github/actions/acceptance-tests-component/action.yml create mode 100644 .github/actions/acceptance-tests-e2e/action.yml delete mode 100644 .github/actions/e2e-tests/action.yml create mode 100644 tests/e2e-tests/api/letters/conftest.py create mode 100644 tests/e2e-tests/lib/letters.py diff --git a/.env.template b/.env.template index 83805d733..69e2590a1 100644 --- a/.env.template +++ b/.env.template @@ -18,6 +18,7 @@ PROXY_NAME= export NON_PROD_API_KEY=xxx export INTEGRATION_API_KEY=xxx export PRODUCTION_API_KEY=xxx +export STATUS_ENDPOINT_API_KEY=xxx # Private Keys # ============ diff --git a/.github/actions/acceptance-tests-component/action.yml b/.github/actions/acceptance-tests-component/action.yml new file mode 100644 index 000000000..5e7a89f85 --- /dev/null +++ b/.github/actions/acceptance-tests-component/action.yml @@ -0,0 +1,38 @@ +name: Acceptance tests - component +description: "Run component acceptance tests for this repo" + +inputs: + testType: + description: Type of test to run + required: true + + targetEnvironment: + description: Name of the environment under test + required: true + + targetComponent: + description: Name of the component under test + required: true + +runs: + using: "composite" + + steps: + + - name: Fetch terraform output + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: terraform-output-${{ inputs.targetComponent }} + + - name: Get Node version + id: nodejs_version + shell: bash + run: | + echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + + - name: Run test - ${{ inputs.testType }} + shell: bash + env: + TARGET_ENVIRONMENT: ${{ inputs.targetEnvironment }} + run: | + make test-${{ inputs.testType }} diff --git a/.github/actions/acceptance-tests-e2e/action.yml b/.github/actions/acceptance-tests-e2e/action.yml new file mode 100644 index 000000000..2d0e503f2 --- /dev/null +++ b/.github/actions/acceptance-tests-e2e/action.yml @@ -0,0 +1,79 @@ +name: Acceptance tests - e2e +description: "Run e2e acceptance tests for this repo" + +inputs: + targetEnvironment: + description: Name of the environment under test + required: true + +runs: + using: "composite" + + steps: + - name: "Set PR NUMBER" + id: set_pr_number + shell: bash + run: | + env="${{ inputs.targetEnvironment }}" + if [[ "$env" == main ]]; then + echo "pr_number=" >> $GITHUB_OUTPUT + elif [[ "$env" == pr* ]]; then + echo "pr_number=${env#pr}" >> $GITHUB_OUTPUT + else + echo "pr_number=$env" >> $GITHUB_OUTPUT + fi + + - name: Determine if proxy has been deployed + id: check_proxy_deployed + env: + GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.set_pr_number.outputs.pr_number }} + shell: bash + run: | + if [[ -z "$PR_NUMBER" ]]; then + echo "No pull request detected; proxy was deployed." + echo "proxy_deployed=true" >> $GITHUB_OUTPUT + exit 0 + fi + + branch_name=${GITHUB_HEAD_REF:-$(echo $GITHUB_REF | sed 's#refs/heads/##')} + + labels=$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name') + echo "Labels on PR #$PR_NUMBER: $labels" + + if echo "$labels" | grep -Fxq 'deploy-proxy'; then + echo "proxy_deployed=true" >> $GITHUB_OUTPUT + else + echo "proxy_deployed=false" >> $GITHUB_OUTPUT + fi + + - name: Install poetry and e2e test dependencies + if: ${{ steps.check_proxy_deployed.outputs.proxy_deployed == 'true' }} + shell: bash + run: | + pipx install poetry + cd tests/e2e-tests && poetry install + + - name: Run tests + if: ${{ steps.check_proxy_deployed.outputs.proxy_deployed == 'true' }} + shell: bash + env: + TARGET_ENVIRONMENT: ${{ inputs.targetEnvironment }} + PR_NUMBER: ${{ steps.set_pr_number.outputs.pr_number }} + run: | + echo "$SUPPLIER_API_PRIVATE_KEY" > "${GITHUB_WORKSPACE}/internal-dev-test-1.pem" + chmod 600 "${GITHUB_WORKSPACE}/internal-dev-test-1.pem" + BASE_PROXY_NAME=nhs-notify-supplier--internal-dev--nhs-notify-supplier + + export API_ENVIRONMENT=internal-dev + if [[ -z "$PR_NUMBER" ]]; then + export PROXY_NAME="${BASE_PROXY_NAME}" + export NON_PROD_API_KEY="${APIM_API_KEY}" + else + export PROXY_NAME="${BASE_PROXY_NAME}-PR-${PR_NUMBER}" + export NON_PROD_API_KEY="${APIM_PR_API_KEY}" + fi + + export STATUS_ENDPOINT_API_KEY="${APIM_STATUS_API_KEY}" + export NON_PROD_PRIVATE_KEY="${GITHUB_WORKSPACE}/internal-dev-test-1.pem" + make .internal-dev-test diff --git a/.github/actions/acceptance-tests/action.yml b/.github/actions/acceptance-tests/action.yml index 2291ca4a2..e6b9eef62 100644 --- a/.github/actions/acceptance-tests/action.yml +++ b/.github/actions/acceptance-tests/action.yml @@ -23,28 +23,21 @@ runs: using: "composite" steps: - - name: Fetch terraform output - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 - with: - name: terraform-output-${{ inputs.targetComponent }} - - - name: Get Node version - id: nodejs_version - shell: bash - run: | - echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - - name: "Repo setup" + - name: Repo setup uses: ./.github/actions/node-install with: GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} + - name: Run component tests + if: ${{ inputs.testType != 'e2e' }} + uses: ./.github/actions/acceptance-tests-component + with: + testType: ${{ inputs.testType }} + targetEnvironment: ${{ inputs.targetEnvironment }} + targetComponent: ${{ inputs.targetComponent }} - - name: "Set PR NUMBER" - shell: bash - run: | - echo "PR_NUMBER=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV - - - name: Run test - ${{ inputs.testType }} - shell: bash - run: | - make test-${{ inputs.testType }} + - name: Run e2e tests + if: ${{ inputs.testType == 'e2e' && inputs.targetEnvironment == 'main' }} + uses: ./.github/actions/acceptance-tests-e2e + with: + targetEnvironment: ${{ inputs.targetEnvironment }} diff --git a/.github/actions/e2e-tests/action.yml b/.github/actions/e2e-tests/action.yml deleted file mode 100644 index f4443b483..000000000 --- a/.github/actions/e2e-tests/action.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: E2E tests -description: "Run end-to-end tests for this repo" - -runs: - using: "composite" - - steps: - - name: Install poetry and e2e test dependencies - shell: bash - run: | - pipx install poetry - cd tests/e2e-tests && poetry install - - - name: Run e2e tests - shell: bash - run: | - echo "$INTERNAL_DEV_TEST_PEM" > "${GITHUB_WORKSPACE}/internal-dev-test-1.pem" - chmod 600 "${GITHUB_WORKSPACE}/internal-dev-test-1.pem" - export PROXY_NAME=nhs-notify-supplier--internal-dev--nhs-notify-supplier - export API_ENVIRONMENT=internal-dev - export NON_PROD_PRIVATE_KEY="${GITHUB_WORKSPACE}/internal-dev-test-1.pem" - make .internal-dev-test diff --git a/.github/actions/test-types.json b/.github/actions/test-types.json index 0aa79285d..fe18d5da7 100644 --- a/.github/actions/test-types.json +++ b/.github/actions/test-types.json @@ -1,4 +1,5 @@ [ "component", + "e2e", "sandbox" ] diff --git a/.github/scripts/dispatch_internal_repo_workflow.sh b/.github/scripts/dispatch_internal_repo_workflow.sh index 714a3fed5..da0e5e28f 100755 --- a/.github/scripts/dispatch_internal_repo_workflow.sh +++ b/.github/scripts/dispatch_internal_repo_workflow.sh @@ -17,7 +17,11 @@ # --overrideRoleName # -# All arguments are required except terraformAction, and internalRef. +# Required arguments are: +# infraRepoName, releaseVersion, targetWorkflow, targetEnvironment, targetComponent, targetAccountGroup. +# +# All other arguments are optional. +# # Example: # ./dispatch_internal_repo_workflow.sh \ # --infraRepoName "nhs-notify-web-template-management" \ @@ -30,7 +34,9 @@ # --internalRef "main" \ # --overrides "tf_var=someString" \ # --overrideProjectName nhs \ -# --overrideRoleName nhs-service-iam-role +# --overrideRoleName nhs-service-iam-role \ +# --extraSecretNames '["MY_API_KEY"]' + set -e @@ -104,6 +110,10 @@ while [[ $# -gt 0 ]]; do version="$2" shift 2 ;; + --extraSecretNames) # JSON array of secret names to fetch in the internal repo (optional) + extraSecretNames="$2" + shift 2 + ;; *) echo "[ERROR] Unknown argument: $1" exit 1 @@ -202,6 +212,10 @@ if [[ -z "$version" ]]; then version="" fi +if [[ -z "$extraSecretNames" ]]; then + extraSecretNames="" +fi + echo "==================== Workflow Dispatch Parameters ====================" echo " infraRepoName: $infraRepoName" echo " releaseVersion: $releaseVersion" @@ -240,6 +254,7 @@ DISPATCH_EVENT=$(jq -ncM \ --arg boundedContext "$boundedContext" \ --arg targetDomain "$targetDomain" \ --arg version "$version" \ + --argjson extraSecretNames "${extraSecretNames:-null}" \ '{ "ref": "'"$internalRef"'", "inputs": ( @@ -255,6 +270,7 @@ DISPATCH_EVENT=$(jq -ncM \ (if $boundedContext != "" then { "boundedContext": $boundedContext } else {} end) + (if $targetDomain != "" then { "targetDomain": $targetDomain } else {} end) + (if $version != "" then { "version": $version } else {} end) + + (if $extraSecretNames != null then { "extraSecretNames": ($extraSecretNames | tojson) } else {} end) + (if $targetAccountGroup != "" then { "targetAccountGroup": $targetAccountGroup } else {} end) + { "releaseVersion": $releaseVersion, diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml index 3d476c2d6..014108496 100644 --- a/.github/workflows/stage-3-build.yaml +++ b/.github/workflows/stage-3-build.yaml @@ -59,8 +59,8 @@ jobs: version: "${{ inputs.version }}" NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - artefact-oas-spec: - name: "Build OAS spec (${{ matrix.apimEnv }})" + artefact-oas-spec-main: + name: "Build OAS spec for main" if: (github.event_name == 'push' && github.ref == 'refs/heads/main') runs-on: ubuntu-latest needs: [artefact-jekyll-docs] @@ -80,6 +80,24 @@ jobs: nodejs_version: ${{ inputs.nodejs_version }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + artefact-oas-spec-pr: + name: "Build OAS spec for PR" + if: (inputs.pr_number != '') + runs-on: ubuntu-latest + needs: [artefact-jekyll-docs] + timeout-minutes: 10 + steps: + - name: "Checkout code" + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: "Build OAS spec" + uses: ./.github/actions/build-oas-spec + with: + version: "${{ inputs.version }}" + apimEnv: internal-dev-pr + buildSandbox: false + nodejs_version: ${{ inputs.nodejs_version }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + artefact-oas-spec-sandbox: name: "Build OAS spec for sandbox" runs-on: ubuntu-latest @@ -97,9 +115,18 @@ jobs: nodejs_version: ${{ inputs.nodejs_version }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + artefact-oas-spec: + name: "OAS spec ready" + runs-on: ubuntu-latest + needs: [artefact-oas-spec-pr, artefact-oas-spec-main] + if: always() && !failure() + steps: + - run: echo "OAS spec build complete" + artefact-sdks: name: "Build SDKs" runs-on: ubuntu-latest + if: always() && !failure() needs: [artefact-oas-spec] timeout-minutes: 10 steps: @@ -165,8 +192,8 @@ jobs: artefact-proxies: name: "Build proxies" runs-on: ubuntu-latest - if: inputs.deploy_proxy == 'true' - needs: [artefact-oas-spec-sandbox, pr-create-dynamic-environment] + if: always() && !failure() && inputs.deploy_proxy == 'true' + needs: [artefact-oas-spec, pr-create-dynamic-environment] timeout-minutes: 10 env: PROXYGEN_API_NAME: nhs-notify-supplier @@ -180,7 +207,7 @@ jobs: with: version: "${{ inputs.version }}" environment: ${{ needs.pr-create-dynamic-environment.outputs.environment_name }} - apimEnv: "internal-dev-sandbox" + apimEnv: "${{ inputs.pr_number == '' && 'internal-dev' || 'internal-dev-pr' }}" runId: "${{ github.run_id }}" buildSandbox: true releaseVersion: ${{ github.head_ref || github.ref_name }} diff --git a/.github/workflows/stage-4-acceptance.yaml b/.github/workflows/stage-4-acceptance.yaml index 520adc266..a86569cfb 100644 --- a/.github/workflows/stage-4-acceptance.yaml +++ b/.github/workflows/stage-4-acceptance.yaml @@ -80,21 +80,5 @@ jobs: --overrideProjectName "nhs" \ --targetEnvironment "$ENVIRONMENT" \ --targetAccountGroup "nhs-notify-supplier-api-dev" \ - --targetComponent "api" - - run-e2e-tests: - name: Run End-to-End Tests - runs-on: ubuntu-latest - if: inputs.proxy_deployed == 'true' - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - name: "Run e2e tests" - #uses: ./.github/actions/e2e-tests - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NON_PROD_API_KEY: ${{ secrets.NON_PROD_API_KEY }} - INTERNAL_DEV_TEST_PEM: ${{ secrets.INTERNAL_DEV_TEST_PEM }} - shell: bash - run: | - echo "E2E tests are currently disabled. See CCM-14778" + --targetComponent "api" \ + --extraSecretNames '["SUPPLIER_API_PRIVATE_KEY","APIM_API_KEY","APIM_PR_API_KEY", "APIM_STATUS_API_KEY"]' diff --git a/.gitleaksignore b/.gitleaksignore index 743843dbe..6758264a1 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -25,3 +25,9 @@ debc75a97cfe551a69fd1e8694be483213322a9d:pact-contracts/pacts/letter-rendering/s 4fa1923947bbff2387218d698d766cbb7c121a0f:pact-contracts/pacts/letter-rendering/supplier-api-letter-request-prepared.json:generic-api-key:10 d005112adcfd286c3bef076214836dbb2fe8d0b5:.npmrc:npm-access-token:9 d005112adcfd286c3bef076214836dbb2fe8d0b5:.npmrc:github-pat:7 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js.map:ipv4:4 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js:ipv4:63 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js:ipv4:62 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js:ipv4:60 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js:ipv4:59 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js:ipv4:24 diff --git a/Makefile b/Makefile index 1d392c18c..c7915eb31 100644 --- a/Makefile +++ b/Makefile @@ -131,6 +131,7 @@ ${VERBOSE}.SILENT: \ ##################### TEST_CMD := APIGEE_ACCESS_TOKEN="$(APIGEE_ACCESS_TOKEN)" \ + STATUS_ENDPOINT_API_KEY="$(STATUS_ENDPOINT_API_KEY)" \ PYTHONPATH=. poetry run pytest --disable-warnings -vv \ --color=yes \ -n 4 \ diff --git a/tests/constants/api-constants.ts b/tests/constants/api-constants.ts index 8bdc66ede..608fb75d8 100644 --- a/tests/constants/api-constants.ts +++ b/tests/constants/api-constants.ts @@ -2,7 +2,7 @@ export const SUPPLIER_LETTERS = "letters"; export const SUPPLIER_API_URL_SANDBOX = "https://internal-dev-sandbox.api.service.nhs.uk/nhs-notify-supplier"; export const AWS_REGION = "eu-west-2"; -export const envName = process.env.PR_NUMBER ?? "main"; +export const envName = process.env.TARGET_ENVIRONMENT ?? "main"; export const API_NAME = `nhs-${envName}-supapi`; export const LETTERSTABLENAME = `nhs-${envName}-supapi-letters`; export const SUPPLIERID = "TestSupplier1"; diff --git a/tests/e2e-tests/README.md b/tests/e2e-tests/README.md index 3a407f313..4b6683d86 100644 --- a/tests/e2e-tests/README.md +++ b/tests/e2e-tests/README.md @@ -1,33 +1,25 @@ # E2E Tests -## Generate An Apigee Access Token +## Set Proxy Name -To generate authentication using Apigee, you must have access to an Apigee account and use `get_token` via the command line and generate an Apigee access token. - -**Tokens expire once per day and require refreshing.** - -* Install [`get_token`](https://docs.apigee.com/api-platform/system-administration/auth-tools#install) -* Run the following command and log in with your Apigee credentials when prompted: +Set the `PROXY_NAME` environment variable to specify the environment for test execution. You can find the proxy name by logging into [Apigee](https://apigee.com/edge), navigating to 'API Proxies' and searching for 'supplier-api' for lower environments like internal-dev. ```shell -export APIGEE_ACCESS_TOKEN=$(SSO_LOGIN_URL=https://login.apigee.com get_token) +export PROXY_NAME=nhs-notify-supplier--internal-dev--nhs-notify-supplier ``` -* If your token does not refresh, try clearing the cache: +Available values for `PROXY_NAME` include: -```shell -export APIGEE_ACCESS_TOKEN=$(SSO_LOGIN_URL=https://login.apigee.com get_token --clear-sso-cache) -``` +* `nhs-notify-supplier--internal-dev--nhs-notify-supplier` +* `nhs-notify-supplier--internal-dev--nhs-notify-supplier-pr` -### Set Proxy Name +## Set Up API Keys -Set the `PROXY_NAME` environment variable to specify the environment for test execution. You can find the proxy name by logging into [Apigee](https://apigee.com/edge), navigating to 'API Proxies' and searching for 'supplier-api' for lower environments like internal-dev. +Set the following environment variables to use the Apigee API keys: ```shell -export PROXY_NAME=nhs-notify-supplier--internal-dev--nhs-notify-supplier +export NON_PROD_API_KEY=****** +export STATUS_ENDPOINT_API_KEY=****** ``` -Available values for `PROXY_NAME` include: - -* `nhs-notify-supplier--internal-dev--nhs-notify-supplier` -* `nhs-notify-supplier--internal-dev--nhs-notify-supplier-pr` +The values have been redacted here but you can obtain them from another team member. diff --git a/tests/e2e-tests/api/data/test_get_letter_data.py b/tests/e2e-tests/api/data/test_get_letter_data.py index 56168f946..42bfc0bef 100644 --- a/tests/e2e-tests/api/data/test_get_letter_data.py +++ b/tests/e2e-tests/api/data/test_get_letter_data.py @@ -4,18 +4,18 @@ from lib.fixtures import * # NOSONAR from lib.constants import LETTERS_ENDPOINT from lib.generators import Generators +from lib.letters import get_pending_letter_ids from lib.errorhandler import ErrorHandler @pytest.mark.test @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_200_get_letter_status(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) - get_letter_id = requests.get(f"{url}/{LETTERS_ENDPOINT}/", headers=headers) +def test_200_get_letter_status(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) + ids = get_pending_letter_ids(url, headers, LETTERS_ENDPOINT, limit=1) - letter_id = get_letter_id.json().get("data")[0].get("id") - get_letter_data = requests.get(f"{url}/{LETTERS_ENDPOINT}/{letter_id}/data", headers=headers) + get_letter_data = requests.get(f"{url}/{LETTERS_ENDPOINT}/{ids[0]}/data", headers=headers) ErrorHandler.handle_retry(get_letter_data) assert get_letter_data.status_code == 200, f"Response: {get_letter_data.status_code}: {get_letter_data.text}" @@ -25,8 +25,8 @@ def test_200_get_letter_status(url, bearer_token): @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_404_letter_does_not_exist(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) +def test_404_letter_does_not_exist(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) get_message_response = requests.get(f"{url}/{LETTERS_ENDPOINT}/xx", headers=headers) ErrorHandler.handle_retry(get_message_response) @@ -37,9 +37,9 @@ def test_404_letter_does_not_exist(url, bearer_token): @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_404_letter_does_not_exist(url, bearer_token): +def test_404_letter_does_not_exist(url, authentication_secret): letter_id = uuid.uuid4().hex - headers = Generators.generate_valid_headers(bearer_token.value) + headers = Generators.generate_valid_headers(authentication_secret) get_message_response = requests.get(f"{url}/{LETTERS_ENDPOINT}/{letter_id}/data", headers=headers) ErrorHandler.handle_retry(get_message_response) diff --git a/tests/e2e-tests/api/headers/test_x_request_id.py b/tests/e2e-tests/api/headers/test_x_request_id.py index ac9726dc1..827b8549d 100644 --- a/tests/e2e-tests/api/headers/test_x_request_id.py +++ b/tests/e2e-tests/api/headers/test_x_request_id.py @@ -6,7 +6,6 @@ METHODS = ["get", "post"] - @pytest.mark.test @pytest.mark.devtest @pytest.mark.inttest @@ -17,10 +16,12 @@ def test_header_letters_endpoint( url, method, endpoints, - bearer_token + authentication_secret ): + auth_header = {"apikey": authentication_secret.value} if authentication_secret.auth_type == "apikey" \ + else {"Authorization": authentication_secret.value} resp = getattr(requests, method)(f"{url}/{endpoints}", headers={ - "Authorization": bearer_token.value, + **auth_header, "X-Request-ID": None }) @@ -33,10 +34,12 @@ def test_header_letters_endpoint( @pytest.mark.prodtest def test_header_mi_endpoint( url, - bearer_token + authentication_secret ): + auth_header = {"apikey": authentication_secret.value} if authentication_secret.auth_type == "apikey" \ + else {"Authorization": authentication_secret.value} resp = getattr(requests, "post")(f"{url}/{MI_ENDPOINT}", headers={ - "Authorization": bearer_token.value, + **auth_header, "X-Request-ID": "" }) diff --git a/tests/e2e-tests/api/letters/conftest.py b/tests/e2e-tests/api/letters/conftest.py new file mode 100644 index 000000000..baf00c479 --- /dev/null +++ b/tests/e2e-tests/api/letters/conftest.py @@ -0,0 +1,14 @@ +import pytest +from lib.letters import create_test_data + + +@pytest.fixture(scope="session", autouse=True) +def seed_letter_test_data(): + """Seed PENDING letters before any test in this directory runs. + + Delegates to the shared letter-test-data CLI, mirroring globalSetup() / + createTestData() used by the component tests + (tests/config/global-setup.ts). Session-scoped so it runs once per + test session. autouse=True means no test needs to reference it explicitly. + """ + create_test_data(count=10) diff --git a/tests/e2e-tests/api/letters/test_get_letter_status.py b/tests/e2e-tests/api/letters/test_get_letter_status.py index 7582e8b19..c55af8820 100644 --- a/tests/e2e-tests/api/letters/test_get_letter_status.py +++ b/tests/e2e-tests/api/letters/test_get_letter_status.py @@ -3,17 +3,19 @@ from lib.fixtures import * # NOSONAR from lib.constants import LETTERS_ENDPOINT from lib.generators import Generators +from lib.letters import get_pending_letter_ids from lib.errorhandler import ErrorHandler @pytest.mark.test @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_200_get_letter_status(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) - get_letters = requests.get(f"{url}/{LETTERS_ENDPOINT}/", headers=headers) +def test_200_get_letter_status(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) + + ids = get_pending_letter_ids(url, headers, LETTERS_ENDPOINT, limit=1) + letter_id = ids[0] - letter_id = get_letters.json().get("data")[0].get("id") get_message_response = requests.get(f"{url}/{LETTERS_ENDPOINT}/{letter_id}", headers=headers) ErrorHandler.handle_retry(get_message_response) @@ -24,8 +26,8 @@ def test_200_get_letter_status(url, bearer_token): @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_404_letter_does_not_exist(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) +def test_404_letter_does_not_exist(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) get_message_response = requests.get(f"{url}/{LETTERS_ENDPOINT}/xx", headers=headers) ErrorHandler.handle_retry(get_message_response) diff --git a/tests/e2e-tests/api/letters/test_get_list_of_letters.py b/tests/e2e-tests/api/letters/test_get_list_of_letters.py index 1290e17b2..41a3afc1a 100644 --- a/tests/e2e-tests/api/letters/test_get_list_of_letters.py +++ b/tests/e2e-tests/api/letters/test_get_list_of_letters.py @@ -1,19 +1,17 @@ -import requests import pytest from lib.fixtures import * # NOSONAR from lib.constants import LETTERS_ENDPOINT from lib.generators import Generators +from lib.letters import get_pending_letter_ids from lib.errorhandler import ErrorHandler @pytest.mark.test @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_200_get_letters(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) +def test_200_get_letters(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) - get_message_response = requests.get(f"{url}/{LETTERS_ENDPOINT}?limit=1", headers=headers) - - ErrorHandler.handle_retry(get_message_response) - assert get_message_response.status_code == 200, f"Response: {get_message_response.status_code}: {get_message_response.text}" - assert get_message_response.json().get("data")[0].get("attributes").get("status") == "PENDING" + ids = get_pending_letter_ids(url, headers, LETTERS_ENDPOINT, limit=1) + assert ids, "Expected at least one PENDING letter" + assert len(ids) == 1 diff --git a/tests/e2e-tests/api/letters/test_multiple_letter_status.py b/tests/e2e-tests/api/letters/test_multiple_letter_status.py index 32ce4766e..744fbb8d4 100644 --- a/tests/e2e-tests/api/letters/test_multiple_letter_status.py +++ b/tests/e2e-tests/api/letters/test_multiple_letter_status.py @@ -5,22 +5,18 @@ from lib.fixtures import * # NOSONAR from lib.constants import LETTERS_ENDPOINT from lib.generators import Generators +from lib.letters import get_pending_letter_ids from lib.errorhandler import ErrorHandler @pytest.mark.test @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_202_with_valid_headers(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) - get_letter_id = requests.get(f"{url}/{LETTERS_ENDPOINT}?limit=2", headers=headers) +def test_202_with_valid_headers(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) - ids = [item.get("id") for item in get_letter_id.json().get("data", [])] - - if ids: - data = Generators.generate_multiple_valid_request(ids) - else: - raise ValueError("No letter IDs returned from API") + ids = get_pending_letter_ids(url, headers, LETTERS_ENDPOINT, limit=2) + data = Generators.generate_multiple_valid_request(ids) update_letter_status = requests.post( f"{url}/{LETTERS_ENDPOINT}", @@ -35,16 +31,11 @@ def test_202_with_valid_headers(url, bearer_token): @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_400_duplicates_in_request_body(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) - get_letter_id = requests.get(f"{url}/{LETTERS_ENDPOINT}?limit=2", headers=headers) - - ids = [item.get("id") for item in get_letter_id.json().get("data", [])] +def test_400_duplicates_in_request_body(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) - if ids: - data = Generators.generate_duplicate_request(ids) - else: - raise ValueError("No letter IDs returned from API") + ids = get_pending_letter_ids(url, headers, LETTERS_ENDPOINT, limit=2) + data = Generators.generate_duplicate_request(ids) update_letter_status = requests.post( f"{url}/{LETTERS_ENDPOINT}", @@ -60,16 +51,11 @@ def test_400_duplicates_in_request_body(url, bearer_token): @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_400_invalid_status_in_request_body(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) - get_letter_id = requests.get(f"{url}/{LETTERS_ENDPOINT}?limit=3", headers=headers) - - ids = [item.get("id") for item in get_letter_id.json().get("data", [])] +def test_400_invalid_status_in_request_body(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) - if ids: - data = Generators.generate_invalid_status_request(ids) - else: - raise ValueError("No letter IDs returned from API") + ids = get_pending_letter_ids(url, headers, LETTERS_ENDPOINT, limit=3) + data = Generators.generate_invalid_status_request(ids) update_letter_status = requests.post( f"{url}/{LETTERS_ENDPOINT}", diff --git a/tests/e2e-tests/api/letters/test_update_letter_status.py b/tests/e2e-tests/api/letters/test_update_letter_status.py index 3313aadbb..28a61c3ff 100644 --- a/tests/e2e-tests/api/letters/test_update_letter_status.py +++ b/tests/e2e-tests/api/letters/test_update_letter_status.py @@ -5,17 +5,18 @@ from lib.fixtures import * # NOSONAR from lib.constants import LETTERS_ENDPOINT from lib.generators import Generators +from lib.letters import get_pending_letter_ids from lib.errorhandler import ErrorHandler @pytest.mark.test @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_202_with_valid_headers(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) - get_letter_id = requests.get(f"{url}/{LETTERS_ENDPOINT}?limit=1", headers=headers) +def test_202_with_valid_headers(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) - letter_id = get_letter_id.json().get("data")[0].get("id") + ids = get_pending_letter_ids(url, headers, LETTERS_ENDPOINT, limit=1) + letter_id = ids[0] data = Generators.generate_valid_message_body("ACCEPTED", letter_id) update_letter_status = requests.patch( @@ -27,11 +28,11 @@ def test_202_with_valid_headers(url, bearer_token): ErrorHandler.handle_retry(update_letter_status) assert update_letter_status.status_code == 202, f"Response: {update_letter_status.status_code}: {update_letter_status.text}" -def test_202_with_rejected_status(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) - get_letter_id = requests.get(f"{url}/{LETTERS_ENDPOINT}?limit=1", headers=headers) +def test_202_with_rejected_status(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) - letter_id = get_letter_id.json().get("data")[0].get("id") + ids = get_pending_letter_ids(url, headers, LETTERS_ENDPOINT, limit=1) + letter_id = ids[0] data = Generators.generate_valid_message_rejected("REJECTED", letter_id) update_letter_status = requests.patch( @@ -47,11 +48,11 @@ def test_202_with_rejected_status(url, bearer_token): @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_400_with_invalid_status(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) - get_letter_id = requests.get(f"{url}/{LETTERS_ENDPOINT}?limit=1", headers=headers) +def test_400_with_invalid_status(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) - letter_id = get_letter_id.json().get("data")[0].get("id") + ids = get_pending_letter_ids(url, headers, LETTERS_ENDPOINT, limit=1) + letter_id = ids[0] data = Generators.generate_valid_message_body("", letter_id) update_letter_status = requests.patch( @@ -67,11 +68,11 @@ def test_400_with_invalid_status(url, bearer_token): @pytest.mark.devtest @pytest.mark.inttest @pytest.mark.prodtest -def test_400_id_mismatch_with_request(url, bearer_token): - headers = Generators.generate_valid_headers(bearer_token.value) - get_letter_id = requests.get(f"{url}/{LETTERS_ENDPOINT}?limit=1", headers=headers) +def test_400_id_mismatch_with_request(url, authentication_secret): + headers = Generators.generate_valid_headers(authentication_secret) - letter_id = get_letter_id.json().get("data")[0].get("id") + ids = get_pending_letter_ids(url, headers, LETTERS_ENDPOINT, limit=1) + letter_id = ids[0] data = Generators.generate_valid_message_body("ACCEPTED", "letter1") update_letter_status = requests.patch( diff --git a/tests/e2e-tests/api/test_endpoint.py b/tests/e2e-tests/api/test_endpoint.py index 5da658419..d8b2bbfc2 100644 --- a/tests/e2e-tests/api/test_endpoint.py +++ b/tests/e2e-tests/api/test_endpoint.py @@ -1,23 +1,20 @@ import pytest import requests -from os import getenv from lib.errorhandler import ErrorHandler - -def _get(url, headers=None, timeout=10): - return requests.get(url, headers=headers or {}, timeout=timeout) +from lib.fixtures import * # NOSONAR +from lib.generators import Generators @pytest.mark.smoketest -def test_ping(nhsd_apim_proxy_url): - resp = requests.get(nhsd_apim_proxy_url + "/_ping") +def test_ping(url): + resp = requests.get(url + "/_ping") assert resp.status_code == 200 @pytest.mark.smoketest @pytest.mark.sandboxtest @pytest.mark.devtest -def test_status(nhsd_apim_proxy_url, status_endpoint_auth_headers): - resp = requests.get( - f"{nhsd_apim_proxy_url}/_status", headers=status_endpoint_auth_headers - ) +def test_status(url, status_authentication_secret): + headers = Generators.generate_valid_headers(status_authentication_secret) + resp = requests.get(f"{url}/_status", headers=headers) ErrorHandler.handle_retry(resp) assert resp.status_code == 200 @@ -25,9 +22,9 @@ def test_status(nhsd_apim_proxy_url, status_endpoint_auth_headers): @pytest.mark.smoketest @pytest.mark.sandboxtest @pytest.mark.devtest -def test_401_status_without_api_key(nhsd_apim_proxy_url): +def test_401_status_without_api_key(url): resp = requests.get( - f"{nhsd_apim_proxy_url}/_status" + f"{url}/_status" ) ErrorHandler.handle_retry(resp) diff --git a/tests/e2e-tests/lib/authentication.py b/tests/e2e-tests/lib/authentication.py index c380cc29b..8ccff5fb4 100644 --- a/tests/e2e-tests/lib/authentication.py +++ b/tests/e2e-tests/lib/authentication.py @@ -26,12 +26,12 @@ def __init__(self): # How long the token will stay valid self.token_validity = 180 - def generate_authentication(self, env, base_url): + def generate_authentication(self, env, base_url, path): # For the test_url, note that we don't need a message_id that actually exists in # the backend. The test will only check that the API doesn't return a 401, # a 404 response means the authentication is working. - test_url = f"{base_url}/letters" + test_url = f"{base_url}{path}" if env == "internal-dev": api_key = os.environ["NON_PROD_API_KEY"] @@ -56,14 +56,21 @@ def generate_authentication(self, env, base_url): else: raise ValueError("Unknown value: ", env) + if path == "/_status": + api_key = os.environ["STATUS_ENDPOINT_API_KEY"] + + if path == "/_status" or "-PR-" in base_url: + # PR environments (and the status endpoint) use AAL0 - authentication is the API key passed directly + return Secret(api_key, auth_type="apikey") + _, latest_token_expiry = self.tokens.get(env, (None, 0)) # Generate new token if latest token will expire in 15 seconds if env not in self.tokens or latest_token_expiry < int(time()) + 15: self.tokens[env] = self.generate_and_test_new_token(api_key, private_key, url, kid, test_url) - bearer_token = self.tokens[env][0] - return Secret(bearer_token) + authentication_secret = self.tokens[env][0] + return Secret(authentication_secret) def generate_and_test_new_token(self, api_key, private_key, url, kid, test_url): new_token = None diff --git a/tests/e2e-tests/lib/fixtures.py b/tests/e2e-tests/lib/fixtures.py index ea1c806c9..e6f33fa96 100644 --- a/tests/e2e-tests/lib/fixtures.py +++ b/tests/e2e-tests/lib/fixtures.py @@ -52,13 +52,15 @@ def authentication_cache(): return AuthenticationCache() @pytest.fixture() -def bearer_token(authentication_cache): +def authentication_secret(url, authentication_cache): environment = os.environ['API_ENVIRONMENT'] - if environment == "prod": - url = "https://api.service.nhs.uk/nhs-notify-supplier" - # the ref2 url is structured slightly differently so it needs to be explicitly called out here - elif environment == "ref": - url = "https://internal-dev.api.service.nhs.uk/nhs-notify-supplier" - else: - url = f"https://{environment}.api.service.nhs.uk/nhs-notify-supplier" - return authentication_cache.generate_authentication(environment, url) + return authentication_cache.generate_authentication(environment, url, "/letters") + +@pytest.fixture() +def status_authentication_secret(url, authentication_cache): + environment = os.environ['API_ENVIRONMENT'] + return authentication_cache.generate_authentication(environment, url, "/_status") + +@pytest.fixture(scope='session') +def status_endpoint_api_key(): + return os.environ["STATUS_ENDPOINT_API_KEY"] diff --git a/tests/e2e-tests/lib/generators.py b/tests/e2e-tests/lib/generators.py index 7749c379a..5a80a15d3 100644 --- a/tests/e2e-tests/lib/generators.py +++ b/tests/e2e-tests/lib/generators.py @@ -13,8 +13,12 @@ def generate_valid_create_message_body(environment="sandbox"): @staticmethod def generate_valid_headers(auth): + if auth.auth_type == "apikey": + auth_header = {"apikey": auth.value} + else: + auth_header = {"Authorization": auth.value} return { - "Authorization": auth, + **auth_header, "X-Request-ID":"123e4567-e89b-12d3-a456-426614174000", } diff --git a/tests/e2e-tests/lib/letters.py b/tests/e2e-tests/lib/letters.py new file mode 100644 index 000000000..5d2a48c8c --- /dev/null +++ b/tests/e2e-tests/lib/letters.py @@ -0,0 +1,82 @@ +import os +import subprocess +import pathlib +import time +import requests + + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +_CLI_WORKSPACE = "nhs-notify-supplier-api-letter-test-data-utility" +_SUPPLIER_ID = "TestSupplier1" + + +def create_test_data(count: int = 10) -> None: + """Seed PENDING letters by delegating to the shared letter-test-data CLI. + + Mirrors createTestData() in tests/helpers/generate-fetch-test-data.ts + so both test suites seed data through the same tool. + """ + environment = os.environ.get("TARGET_ENVIRONMENT", "main") + aws_account_id = os.environ.get("AWS_ACCOUNT_ID", "820178564574") + + cmd = [ + "npm", + "-w", + _CLI_WORKSPACE, + "run", + "cli", + "--", + "create-letter-batch", + "--supplier-id", _SUPPLIER_ID, + "--environment", environment, + "--awsAccountId", aws_account_id, + "--group-id", "TestGroupID", + "--specification-id", "TestSpecificationID", + "--status", "PENDING", + "--count", str(count), + "--test-letter", "test-letter-standard", + ] + + result = subprocess.run(cmd, cwd=_REPO_ROOT, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError( + f"create-letter-batch CLI failed (exit code {result.returncode}).\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + + +def get_pending_letter_ids( + url: str, + headers: dict, + letters_endpoint: str, + limit: int = 1, + timeout_s: int = 20, + interval_s: int = 2, + retries: int = 5, +) -> list: + """Injects the given number of pending letters as test data, then waits for them to become + visible via the letters endpoint. Retries to account for other tests running in parallel stealing the letters + + Returns a list of letter ID strings. + Raises TimeoutError if fewer than `limit` letters are returned after all retries are exhausted. + """ + + for _ in range(retries): + create_test_data(limit) + deadline = time.monotonic() + timeout_s + data = [] + while time.monotonic() < deadline: + response = requests.get( + f"{url}/{letters_endpoint}?limit={limit}", headers=headers + ) + response.raise_for_status() + data.extend(response.json().get("data", [])) + if len(data) >= limit: + return [item.get("id") for item in data] + time.sleep(interval_s) + + raise TimeoutError( + f"Timed out after {retries} retries waiting for {limit} PENDING letter(s) at " + f"{url}/{letters_endpoint}" + ) diff --git a/tests/e2e-tests/lib/secret.py b/tests/e2e-tests/lib/secret.py index e99a1e568..734d0473f 100644 --- a/tests/e2e-tests/lib/secret.py +++ b/tests/e2e-tests/lib/secret.py @@ -1,6 +1,7 @@ class Secret: - def __init__(self, value): + def __init__(self, value, auth_type="bearer"): self.value = value + self.auth_type = auth_type def __repr__(self): return "Secret(********)" From 48052c53d832cb41236104b9ab9b4c9c16036619 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Fri, 17 Apr 2026 16:38:21 +0100 Subject: [PATCH 2/2] Point to my internal PR (DO NOT CHECK IN) --- .github/workflows/stage-4-acceptance.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/stage-4-acceptance.yaml b/.github/workflows/stage-4-acceptance.yaml index a86569cfb..c76713819 100644 --- a/.github/workflows/stage-4-acceptance.yaml +++ b/.github/workflows/stage-4-acceptance.yaml @@ -78,6 +78,7 @@ jobs: --infraRepoName "nhs-notify-supplier-api" \ --releaseVersion "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" \ --overrideProjectName "nhs" \ + --internalRef "feature/CCM-14778" \ --targetEnvironment "$ENVIRONMENT" \ --targetAccountGroup "nhs-notify-supplier-api-dev" \ --targetComponent "api" \