diff --git a/.github/workflows/cpi-integration-test.yml b/.github/workflows/cpi-integration-test.yml new file mode 100644 index 0000000000..1b375a191b --- /dev/null +++ b/.github/workflows/cpi-integration-test.yml @@ -0,0 +1,102 @@ +name: 'CPI-Integration Test' + +on: + workflow_dispatch: + workflow_call: + schedule: + - cron: '0 6 * * 1-5' + push: + branches: [ 'mw/dpc-5238-integration-test' ] + +permissions: + id-token: write + contents: read + +concurrency: + group: integration-test-${{ inputs.env }} + cancel-in-progress: false + +jobs: + cpi-integration-test: + defaults: + run: + working-directory: ./dpc-load-testing + name: CPI Integration Test + runs-on: codebuild-dpc-app-${{github.run_id}}-${{github.run_attempt}} + steps: + - uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 + name: Slack Starting + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + # Sends to dpc-deploys + payload: | + channel: "CMC1E4AEQ" + attachments: + - color: warning + text: "Integration test for CPI Gateway has started" + footer: "<${{ github.server_url}}/${{ github.repository}}/actions/runs/${{ github.run_id }}|Integration test - Build ${{ github.run_id }}>" + mrkdown_in: + - footer + - name: "Checkout code" + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - name: AWS Credentials (non-prod) + uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1 + with: + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: arn:aws:iam::${{ secrets.NON_PROD_ACCOUNT_ID }}:role/delegatedadmin/developer/dpc-test-github-actions + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '>=1.24.4' + - name: Build K6 + run: | + # Install xk6 + go install go.k6.io/xk6/cmd/xk6@v1.1.5 + + # Build the k6 binary + xk6 build + - name: Set env vars from AWS params + uses: cmsgov/cdap/actions/aws-params-env-action@main + env: + AWS_REGION: ${{ vars.AWS_REGION }} + with: + params: | + CPI_API_GW_TESTDATA=/dpc/test/web-portal/cpi_api_gw_testdata + CLIENT_ID=/dpc/test/web-portal/cpi_api_gw_client_id + CLIENT_SECRET=/dpc/test/web-portal/cpi_api_gw_client_secret + - name: Run CPI Gateway Integration Test + id: run-cpi-gateway-integration-test + env: + ENVIRONMENT: test + run: | + ./k6 run ./integration_test/cpi_gateway_test.js + - uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 + name: Slack Success + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + # Sends to dpc-deploys + payload: | + channel: "CMC1E4AEQ" + attachments: + - color: good + text: "Integration test for CPI Gateway has succeeded" + footer: "<${{ github.server_url}}/${{ github.repository}}/actions/runs/${{ github.run_id }}|Integration Test - Build ${{ github.run_id }}>" + mrkdown_in: + - footer + - uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 + name: Slack failure + if: ${{ failure() }} + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + # Sends to dpc-deploys + payload: | + channel: "CMC1E4AEQ" + attachments: + - color: danger + text: "Integration test for CPI Gateway has has failed" + footer: "<${{ github.server_url}}/${{ github.repository}}/actions/runs/${{ github.run_id }}|integration Test - Build ${{ github.run_id }}>" + mrkdown_in: + - footer diff --git a/Makefile b/Makefile index 8850929169..d4fe91775a 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ # ============== SMOKE_THREADS ?= 10 +SHELL := /bin/bash venv: venv/bin/activate @@ -18,6 +19,12 @@ smoke/local: start-dpc @echo "Running Smoke Tests against Local env" docker run --rm -v $(shell pwd)/dpc-load-testing:/src -e ENVIRONMENT=local -i grafana/k6:1.4.2 run /src/smoke-test.js + +.PHONY: it/local +it/local: + . ./scripts/export_ssm_parameters.sh && \ + docker run --rm -v $(shell pwd)/dpc-load-testing:/src -e ENVIRONMENT=local -e CLIENT_ID -e CLIENT_SECRET -e CPI_API_GW_TESTDATA -i grafana/k6:1.4.2 run /src/integration_test/cpi_gateway_test.js + # Build commands # # These commands build/compile our applications and docker images. diff --git a/dpc-load-testing/integration_test/cpi_gateway_test.js b/dpc-load-testing/integration_test/cpi_gateway_test.js new file mode 100644 index 0000000000..61209edd5b --- /dev/null +++ b/dpc-load-testing/integration_test/cpi_gateway_test.js @@ -0,0 +1,219 @@ +/*global console*/ +/* eslint no-console: "off" */ + +import { check } from 'k6'; +import exec from 'k6/execution'; +import { isEmptyObject, isObjectType, isDate, isArrayType } from '../utils/test-utils.js'; +import { getToken } from '../oauth-client.js'; +import http from 'k6/http'; + +var PROVIDERS_PATH = "api/1.0/ppr/providers"; +var ORG_PROVIDER_PROFILE_PATH = "api/1.0/ppr/providers/profile"; + + +//var PROFILE_PATH = "api/1.0/ppr/providers/profiles"; + +// No longer needed as we started to use SSM Parameter Store to aid local dev and testing. +//var testData = __ENV.ENVIRONMENT == 'local' ? JSON.parse(open('./cpi_test_data.secret')) : {} + +export const options = { + thresholds: { + checks: ['rate===1'], + }, + insecureSkipTLSVerify: true, + scenarios: { + smokeTests: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 1, + exec: "runCPITests" + + } + } +}; + +// This function aggregates the config data from different sources based upon +// the environment and presents unified source for subsequent tests. +function getConfig() { + return { + ...JSON.parse(__ENV.CPI_API_GW_TESTDATA), + ...{ clientId: __ENV.CLIENT_ID, clientSecret: __ENV.CLIENT_SECRET } + }; +} + +export function setup() { + + var env = __ENV.ENVIRONMENT; + console.log(`Running CPI Gateway Integration Test with environment ${env}`); + + var config = getConfig(); + //console.log(`Merged Config for CPI Gateway Integration Test: ${JSON.stringify(config)}`); + + if (isEmptyObject(config)) { + console.error("\nTest data not found in environment variable CPI_API_GW_TESTDATA"); + exec.test.abort('failed to check for existing orgs'); + } + + var cpiConfig = config.meta.configuration['test']; + + const oauthEndpointBaseUrl = cpiConfig["OAUTH_URL"]; + const oauthEndpointTokenPath = cpiConfig["TOKEN_ENDPOINT"]; + const clientId = config.clientId; + const clientSecret = config.clientSecret; + + if (!oauthEndpointBaseUrl || !oauthEndpointTokenPath || !clientId || !clientSecret) { + console.error("Missing required environment variables for OAuth token retrieval"); + exec.test.abort("Error: missing environment variables"); + } + + const baseUrl = `${oauthEndpointBaseUrl}/${oauthEndpointTokenPath}`; + const token = getToken(baseUrl, clientId, clientSecret, "READ"); + if (!token) { + console.error("Failed to retrieve access token for CPI Gateway Integration Test"); + exec.test.abort("Error: Failed to retrieve access token"); + } + + return { + cpiBaseUrl: cpiConfig["BASE_URL"], + token: token, + testData: config.data + }; + +} + +export function runCPITests(params) { + //console.log("Running CPI Gateway Integration Test with config: " + JSON.stringify(params)); + const { cpiBaseUrl, token, testData } = params; + + var testDataForMedSanctions = testData["AO_WITH_MED_SANCTIONS"]; + const providerResponse = getProvider(cpiBaseUrl, token, "ind", "ssn", testDataForMedSanctions.ao_ssn); + + check( + providerResponse, + { + 'get provider returns 200': res => res.status == 200, + 'provider data type': res => isObjectType(res.json(), 'provider'), + 'provider data returned': res => !isEmptyObject(res.json().provider), + 'med sanctions data returned': res => isArrayType(res.json().provider, 'medSanctions') && + res.json().provider.medSanctions.length > 0, + 'med sanction code returned': res => res.json().provider.medSanctions[0].sanctionCode != null, + 'med sanction date returned in right format': res => isDate(res.json().provider.medSanctions[0].sanctionDate), + } + ) + + var testDataForWaivers = testData["AO_WITH_WAIVERS"]; + const waiverResponse = getProvider(cpiBaseUrl, token, "ind", "ssn", testDataForWaivers.ao_ssn); + check( + waiverResponse, + { + 'get provider returns 200': res => res.status == 200, + 'provider data type is object': res => isObjectType(res.json(), 'provider'), + 'provider data returned': res => !isEmptyObject(res.json().provider), + 'waiver data returned': res => isArrayType(res.json().provider, 'waiverInfo') && + res.json().provider.waiverInfo.length > 0, + 'waiver effectiveDate returned': res => res.json().provider.waiverInfo[0].effectiveDate != null && + isDate(res.json().provider.waiverInfo[0].effectiveDate), + 'waiver endDate valid': res => res.json().provider.waiverInfo[0].endDate && + isDate(res.json().provider.waiverInfo[0].endDate), + } + ) + + var testDataForUnapprovedEnrollmentStatus = testData["UNAPPROVED_ENROLLMENT_STATUS"]; + const orgProviderResponse = getProviderOrg(cpiBaseUrl, token, testDataForUnapprovedEnrollmentStatus.org_npi); + check( + orgProviderResponse, + { + 'get org provider returns 200': res => res.status == 200, + 'org provider data type is object': res => isObjectType(res.json(), 'provider'), + 'org provider data returned': res => !isEmptyObject(res.json().provider), + 'enrollments data returned': res => isArrayType(res.json().provider, 'enrollments') && + res.json().provider.enrollments.length > 0, + 'unapproved enrollment status': res => res.json().provider.enrollments.every( + enrollment => enrollment.status != "APPROVED") + } + ) + + var testDataForOrgWithAOInfo = testData["ORG_WITH_AO_SSN"]; + const orgProviderWithAOResponse = getProviderOrg(cpiBaseUrl, token, testDataForOrgWithAOInfo.org_npi); + const ssnNoHyphens = /^\d{9}$/; + check( + orgProviderWithAOResponse, + { + 'get org provider returns 200': res => res.status == 200, + 'org provider data type is object': res => isObjectType(res.json(), 'provider'), + 'org provider data returned': res => !isEmptyObject(res.json().provider), + 'enrollments data returned': res => isArrayType(res.json().provider, 'enrollments') && + res.json().provider.enrollments.length > 0, + 'approved enrollment status': res => res.json().provider.enrollments.some( + enrollment => enrollment.status === "APPROVED"), + 'roles data returned': res => { + let activeEnrollment = res.json().provider.enrollments.find(enrollment => enrollment.status === "APPROVED"); + return !!activeEnrollment && isArrayType(activeEnrollment, 'roles') && activeEnrollment.roles.length > 0; + }, + 'AO info returned for org': res => { + let activeEnrollment = res.json().provider.enrollments.find(enrollment => enrollment.status === "APPROVED"); + let roles = activeEnrollment ? activeEnrollment.roles : []; + return roles.some(role => role.roleCode === "10" + && role.dataIndicator === "CURRENT" + && !!role.ssn + && ssnNoHyphens.test(role.ssn) + ); + } + + } + ) + +} + +function getProvider(cpiBaseUrl, token, type, idType, id) { + const url = `${cpiBaseUrl}/${PROVIDERS_PATH}`; + const payload = providerRequest(type, idType, id); + const params = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + }; + return http.post(url, payload, params); + +} + +function getProviderOrg(cpiBaseUrl, token, orgNpi) { + const url = `${cpiBaseUrl}/${ORG_PROVIDER_PROFILE_PATH}`; + const payload = orgProviderRequest(orgNpi); + const params = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + }; + return http.post(url, payload, params); +} + +function orgProviderRequest(npi) { + return JSON.stringify({ "providerID": { "npi": npi } }); +} + +function providerRequest(type, idType, id) { + return JSON.stringify( + { + "providerID": { + "providerType": type, + "identity": { + "idType": idType, + "id": id + } + }, + "dataSets": { + "all": true + } + }); +} + + + + + + + + diff --git a/dpc-load-testing/oauth-client.js b/dpc-load-testing/oauth-client.js new file mode 100644 index 0000000000..ed59de6e42 --- /dev/null +++ b/dpc-load-testing/oauth-client.js @@ -0,0 +1,34 @@ +/*global console*/ +/* eslint no-console: "off" */ + +import encoding from 'k6/encoding'; +import http from 'k6/http'; + +export function getToken(oauthTokenUrl, clientId, clientSecret, scope) { + + const payload = { + grant_type: 'client_credentials', + scope: scope + }; + + let encodedCreds = 'Basic ' + encodeCreds(clientId, clientSecret); + const params = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': encodedCreds + }, + }; + + const response = http.post(oauthTokenUrl, payload, params); + if (response.status !== 200) { + console.error(`Failed to retrieve token: ${response.body}`); + return null; + } + + const responseBody = response.json(); + return responseBody.access_token; +} + +export function encodeCreds(userName, password) { + return encoding.b64encode(`${userName}:${password}`); +} \ No newline at end of file diff --git a/dpc-load-testing/resource-request-bodies.js b/dpc-load-testing/resource-request-bodies.js index 8d98c2656f..593fd0ef57 100644 --- a/dpc-load-testing/resource-request-bodies.js +++ b/dpc-load-testing/resource-request-bodies.js @@ -103,12 +103,12 @@ export function generateBundle(entries) { "resourceType": "Parameters", "parameter": [ { - "name": "resource", - "resource": { + "name": "resource", + "resource": { "resourceType": "Bundle", "type": "collection", "entry": entries, - } + } } ] } diff --git a/dpc-load-testing/utils/test-utils.js b/dpc-load-testing/utils/test-utils.js index 6fb0ce2725..a195811421 100644 --- a/dpc-load-testing/utils/test-utils.js +++ b/dpc-load-testing/utils/test-utils.js @@ -1,3 +1,20 @@ export function isArrayUnique(arr) { return Array.isArray(arr) && new Set(arr).size === arr.length; } + +export function isEmptyObject(obj) { + return obj && Object.keys(obj).length === 0 && obj.constructor === Object; +} + +export function isObjectType(obj, key) { + return obj && obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key]); +} + +export function isArrayType(obj, key) { + return obj && obj[key] && Array.isArray(obj[key]); +} + +export function isDate(dateString) { + const date = Date.parse(dateString); + return !isNaN(date); +} diff --git a/scripts/export_ssm_parameters.sh b/scripts/export_ssm_parameters.sh new file mode 100755 index 0000000000..ac29c9bf68 --- /dev/null +++ b/scripts/export_ssm_parameters.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Array with values using "ENV_VAR_NAME:SSM_PARAMETER_NAME" format +PROJECT="dpc" +ENV="test" +APP="web-portal" +PREFIX="/${PROJECT}/${ENV}/${APP}" + +ENV_SSM_MAPPINGS=( + "CLIENT_ID:${PREFIX}/cpi_api_gw_client_id" + "CLIENT_SECRET:${PREFIX}/cpi_api_gw_client_secret" + "CPI_API_GW_TESTDATA:${PREFIX}/cpi_api_gw_testdata" +) + +# 1. Validation: Check AWS Credentials +echo "Validating AWS credentials..." +if ! aws sts get-caller-identity --query "Arn" --output text > /dev/null 2>&1; then + echo "Error: AWS credentials not found or expired." + return 1 2>/dev/null || exit 1 +fi + +# 2. Collect all paths for the batch call by splitting the "ENV_VAR:SSM_PATH" entries +SSM_PATHS="" +for ENTRY in "${ENV_SSM_MAPPINGS[@]}"; do + SSM_PATH="${ENTRY#*:}" # Extract everything after the colon + SSM_PATHS="$SSM_PATHS $SSM_PATH" +done + +echo "Fetching parameters in batch..." + +# 3. Batch fetch (returns "Path Value" pairs) +# Use --output json to enable querying using jq in next step +JSON_RESPONSE=$(aws ssm get-parameters \ + --names $SSM_PATHS \ + --with-decryption \ + --output json) + +# 4. Loop through our mappings and extract values using jq +for ENTRY in "${ENV_SSM_MAPPINGS[@]}"; do + ENV_VAR="${ENTRY%%:*}" + SSM_PATH="${ENTRY#*:}" + + echo "Searching for ENV_VAR: $ENV_VAR, SSM_PATH: $SSM_PATH" + + # Use jq to find the value where Name == SSM_PATH + # The '-r' flag in jq is CRITICAL: it outputs "raw" text (unquoted) + + VALUE=$(echo "$JSON_RESPONSE" | jq -r --arg PATH "$SSM_PATH" \ + '.Parameters[] | select(.Name == $PATH) | .Value') + + if [ -n "$VALUE" ]; then + export "$ENV_VAR"="$VALUE" + # echo "Exported $ENV_VAR (Length: ${#VALUE} chars)" + else + echo "Error: $SSM_PATH not found." + return 1 2>/dev/null || exit 1 + fi +done \ No newline at end of file