diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index 048e3e7c74..70f8676cf0 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -32,7 +32,6 @@ concurrency: env: VAULT_PW: ${{ secrets.VAULT_PW }} - REPORT_COVERAGE: true DPC_CA_CERT: ${{ secrets.DPC_CA_CERT }} ENV: "github-ci" @@ -40,6 +39,8 @@ jobs: build-api: name: "Build and Test API" runs-on: codebuild-dpc-app-${{github.run_id}}-${{github.run_attempt}} + env: + REPORT_COVERAGE: true steps: - name: Assert Ownership run: sudo chmod -R 777 . @@ -194,8 +195,8 @@ jobs: if: ${{ always() }} run: ./scripts/cleanup-docker.sh - build-dpc-client: - name: "Build and Test DPC Client" + build-dpc-api-client: + name: "Build and Test DPC API Client" runs-on: codebuild-dpc-app-${{github.run_id}}-${{github.run_attempt}} steps: - name: Assert Ownership @@ -204,9 +205,38 @@ jobs: uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Cleanup Runner run: ./scripts/cleanup-docker.sh - - name: "DPC Client Build" + - name: Install docker compose manually run: | - make ci-api-client + mkdir -p /usr/local/lib/docker/cli-plugins + curl -SL https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose + chown root:root /usr/local/lib/docker/cli-plugins/docker-compose + chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + - name: "API Client Unit Tests" + run: make ci-api-client + - name: "Set up Python and install Ansible" + run: | + sudo dnf -y install python3 python3-pip + pip install ansible + - name: "Set up JDK 17" + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + java-version: "17" + distribution: "corretto" + cache: maven + - name: Clean maven + run: mvn -ntp -U clean + - name: "API Client Integration test" + id: integration-test + run: make ci-api-client-integration + - name: "Debug db" + if: ${{ failure() && steps.integration-test.outcome == 'failure' }} + run: docker logs api-client-integration-app-db-1 + - name: "Debug attribution" + if: ${{ failure() && steps.integration-test.outcome == 'failure' }} + run: docker logs api-client-integration-app-attribution-1 + - name: "Debug api" + if: ${{ failure() && steps.integration-test.outcome == 'failure' }} + run: docker logs api-client-integration-app-api-1 - name: Cleanup if: ${{ always() }} run: ./scripts/cleanup-docker.sh diff --git a/Makefile b/Makefile index a7e046d6db..8850929169 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,8 @@ portal: cp -r engines/api_client/ dpc-portal/vendor/api_client/ @docker build -f dpc-portal/Dockerfile . -t dpc-web-portal +api-client: + @docker compose -f docker-compose.yml -f docker-compose.api-client.yml build dpc_api_client # Start commands # ============== @@ -178,6 +180,8 @@ portal-sh: ## Run a portal shell portal-console: ## Run a rails console shell @docker compose -f docker-compose.yml -f docker-compose.portals.yml exec -it dpc_portal bin/console +api-client-sh: + @docker compose -f docker-compose.api-client.yml run --remove-orphans --entrypoint "sh" dpc_api_client # Build & Test commands # ====================== @@ -228,3 +232,7 @@ unit-tests: .PHONY: load-tests load-tests: start-api-load-tests start-load-tests down-dpc-load-tests + +.PHONY: ci-api-client-integration +ci-api-client-integration: docker-base secure-envs + @bash ./dpc-api-client-integration-test.sh diff --git a/docker-compose.api-client.yml b/docker-compose.api-client.yml new file mode 100644 index 0000000000..776641003d --- /dev/null +++ b/docker-compose.api-client.yml @@ -0,0 +1,18 @@ +services: + dpc_api_client: + build: + context: . + dockerfile: engines/api_client/Dockerfile + image: dpc-api-client:latest + volumes: + # Mount specific directories to avoid overwriting + # precompiled assets (public/assets/) and node_modules + - "./engines/api_client:/api-client" + environment: + # Application settings + - GOLDEN_MACAROON=${GOLDEN_MACAROON} + - API_METADATA_URL=http://api:3002/api/v1 + - API_ADMIN_URL=http://api:9900 + - RUBY_YJIT_ENABLE=1 + - SKIP_SIMPLE_COV=${SKIP_SIMPLE_COV:-} + - ENV=local diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 4af782a5dd..e5e63149a3 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -3,6 +3,6 @@ services: image: dpc-base:latest build: args: - PACE_CERT: $PACE_CERT + PACE_CERT: ${PACE_CERT:-} context: . dockerfile: docker/Dockerfiles/Dockerfile.base diff --git a/docker-compose.yml b/docker-compose.yml index 3fb29356ce..4f38bd496d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: - ENV=local - USE_BFD_MOCK=${USE_BFD_MOCK:-true} - EMIT_AWS_METRICS=${EMIT_AWS_METRICS:-false} - - JACOCO=${REPORT_COVERAGE:-false} + - JACOCO=${REPORT_COVERAGE:-} - DEBUG_MODE=${DEBUG_MODE:-false} depends_on: db: @@ -56,7 +56,7 @@ services: condition: service_healthy environment: - ENV=local - - JACOCO=${REPORT_COVERAGE:-false} + - JACOCO=${REPORT_COVERAGE:-} - DEBUG_MODE=${DEBUG_MODE:-false} ports: - "3500:8080" @@ -79,7 +79,7 @@ services: - ./ops/config/decrypted/local.env environment: - ENV=local - - JACOCO=${REPORT_COVERAGE:-false} + - JACOCO=${REPORT_COVERAGE:-} - EXPORT_PATH=/app/data - AUTH_DISABLED=${AUTH_DISABLED:-false} - DEBUG_MODE=${DEBUG_MODE:-false} diff --git a/dpc-api-client-integration-test.sh b/dpc-api-client-integration-test.sh new file mode 100755 index 0000000000..043d15b011 --- /dev/null +++ b/dpc-api-client-integration-test.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -Ee + +echo "┌-------------------───────────────────────┐" +echo "│ │" +echo "│ Running API Client Gem Integration Tests |" +echo "│ │" +echo "└------------─────────-------──────────────┘" + +# Current working directory +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" + +# Configure the Maven log level +export MAVEN_OPTS=-Dorg.slf4j.simpleLogger.defaultLogLevel=info + +# Include secure environment variables +set -o allexport +[[ -f ${DIR}/ops/config/decrypted/local.env ]] && source ${DIR}/ops/config/decrypted/local.env +set +o allexport + +# Remove jacocoReport directory +if [ -d "${DIR}/jacocoReport" ]; then + rm -r "${DIR}/jacocoReport" +fi + +function _finally { + # don't shut it down if running on ci + if [ "$ENV" != 'github-ci' ]; then + echo "SHUTTING EVERYTHING DOWN" + docker compose -p api-client-integration-app down + docker volume rm api-client-integration-app_pgdata16 + fi +} + +trap _finally EXIT + +# Build the application +mvn -T 1.5C clean compile -Perror-prone -B -V -ntp -DskipTests +mvn -T 1.5C package -Pci -ntp -DskipTests + +echo "Starting api server for end-to-end tests" +USE_BFD_MOCK=true docker compose -p api-client-integration-app up api --wait + +echo "Starting integration tests" +GOLDEN_MACAROON=$(curl -X POST http://localhost:9903/tasks/generate-token) \ +SKIP_SIMPLE_COV=true \ +docker compose -p api-client-integration-app \ +-f docker-compose.yml -f docker-compose.api-client.yml \ +run --remove-orphans \ +--entrypoint "bundle exec rspec --order defined --tag type:integration" \ +dpc_api_client + +echo "┌───────────-------──────────-------------──┐" +echo "│ │" +echo "│ API Client Gem Integration Tests Complete |" +echo "│ │" +echo "└────────────────────--------------------───┘" diff --git a/dpc-api-client-test.sh b/dpc-api-client-test.sh index 28ae35cb51..76d126bd92 100755 --- a/dpc-api-client-test.sh +++ b/dpc-api-client-test.sh @@ -8,7 +8,7 @@ echo "│ │" echo "└───────────────────────┘" # Build the container -docker build -f engines/api_client/Dockerfile engines/api_client -t api-client +docker compose -f docker-compose.api-client.yml build dpc_api_client # Run the tests echo "┌───────────────────────────┐" @@ -16,8 +16,8 @@ echo "│ │" echo "│ Running Api Gem Tests │" echo "│ │" echo "└───────────────────────────┘" -docker run --rm -v ${PWD}/engines/api_client/coverage:/api-client/coverage api-client bundle exec rspec -docker run --rm api-client bundle exec rubocop +docker run --rm -v ${PWD}/engines/api_client/coverage:/api-client/coverage dpc-api-client bundle exec rspec +docker run --rm dpc-api-client bundle exec rubocop echo "┌────────────────────────────────┐" echo "│ │" diff --git a/engines/api_client/Dockerfile b/engines/api_client/Dockerfile index 08ba06f593..089a371e2a 100644 --- a/engines/api_client/Dockerfile +++ b/engines/api_client/Dockerfile @@ -12,13 +12,13 @@ RUN mkdir /api-client WORKDIR /api-client # Copy over the files needed to fetch dependencies -COPY Gemfile Gemfile.lock api_client.gemspec /api-client/ -COPY lib /api-client/lib +COPY engines/api_client/Gemfile engines/api_client/Gemfile.lock engines/api_client/api_client.gemspec /api-client/ +COPY engines/api_client/lib /api-client/lib RUN gem install bundler --no-document && \ bundle config set force_ruby_platform true && \ bundle install -COPY app /api-client/app -COPY spec /api-client/spec -COPY coverage /api-client/coverage -COPY .rubocop.yml /api-client/.rubocop.yml +COPY engines/api_client/app /api-client/app +COPY engines/api_client/spec /api-client/spec +COPY engines/api_client/coverage /api-client/coverage +COPY engines/api_client/.rubocop.yml /api-client/.rubocop.yml diff --git a/engines/api_client/Makefile b/engines/api_client/Makefile deleted file mode 100644 index cd367607fe..0000000000 --- a/engines/api_client/Makefile +++ /dev/null @@ -1,8 +0,0 @@ -build: - docker build . -t api_client -test: - docker run --rm -v ${PWD}:/api-client -w /api-client api_client bundle exec rspec - docker run --rm -v ${PWD}:/api-client -w /api-client api_client bundle exec rubocop - -run: - docker run -it --rm -v ${PWD}:/api-client -w /api-client api_client sh diff --git a/engines/api_client/README.md b/engines/api_client/README.md index 01fff1cbf1..76c0ac60f4 100644 --- a/engines/api_client/README.md +++ b/engines/api_client/README.md @@ -20,20 +20,27 @@ And then execute: $ bundle install ``` -## Testing -Build the docker image - -In the api_client directory +## Debugging and Development +To build an image, use the `make` command in the project root directory. ```bash -$ make build +make api-client ``` -Run the tests until they pass + +To ssh into a Docker container with the dpc_client code, use the `make` command in the project root directory. +```bash +make api-client-sh ``` -$ make test + +## Testing +Test using `make` commands in the project root directory. + +### Unit Tests +```bash +make ci-api-client ``` -Jump into the docker shell for iterative development +### Integration tests with the API +```bash +make ci-api-client-integration ``` -make run -``` \ No newline at end of file diff --git a/engines/api_client/spec/integration/dpc_client_spec.rb b/engines/api_client/spec/integration/dpc_client_spec.rb new file mode 100644 index 0000000000..3bae4cc463 --- /dev/null +++ b/engines/api_client/spec/integration/dpc_client_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require 'base64' +require 'openssl' +require 'rails_helper' + +RSpec.describe DpcClient, type: :integration do + let(:client) { DpcClient.new } + let(:npi) { '1111111112' } + let!(:org) do + double('organization', + npi:, name: 'Org 2', address_use: 'work', address_type: 'both', + address_city: 'Akron', address_state: 'OH', address_street: '111 Main ST', 'address_street_2' => 'STE 5', + id: '8453e48b-0b42-4ddf-8b43-07c7aa2a3d88', + address_zip: '22222') + end + + before(:all) do + WebMock.disable! + end + after(:all) do + WebMock.enable! + end + + describe '#create_organization' do + it 'sends data to API and sets response instance variables' do + client.create_organization(org) + expect(client.response_status).to eq(200) + expect(client.response_body.dig('identifier', 0, 'value')).to eq npi + end + end + + describe '#get_organization_by_npi' do + it 'retrieves organization data from API' do + response = client.get_organization_by_npi(npi) + expect(response&.entry).to_not be_nil + expect(response.entry.length).to eq 1 + expect(response.entry.first.resource.identifier.first.value).to eq npi + end + end + + describe 'with existing org' do + let(:org_id) do + response = client.get_organization_by_npi(npi) + response.entry.first.resource.id + end + + describe '#get_organization' do + it 'retrieves organization data from API' do + response = client.get_organization(org_id) + expect(response).to_not be_nil + expect(response.resourceType).to eq 'Organization' + end + end + + describe '#update_organization' do + it 'sends org data to API' do + expect(client.update_organization(org, org_id)).to eq(client) + expect(client.response_successful?).to eq(true) + end + end + + describe 'client tokens' do + let(:label) { 'Sandbox Token 1' } + context 'create' do + it 'sends data to API and sets response instance variables' do + client.create_client_token(org_id, params: { label: label }) + + expect(client.response_status).to eq(200) + expect(client.response_body['label']).to eq(label) + end + end + + context 'list' do + it 'sends data to API and sets response instance variables' do + client.get_client_tokens(org_id) + + expect(client.response_status).to eq(200) + expect(client.response_body['count']).to be > 0 + expect(client.response_body['entities'].first['label']).to eq(label) + end + end + + context 'delete' do + it 'returns success' do + # Need to get an existing id first + client.get_client_tokens(org_id) + expect(client.response_status).to eq(200) + start_count = client.response_body['count'] + expect(start_count).to be > 0 + token_id = client.response_body['entities'].first['id'] + + client.delete_client_token(org_id, token_id) + + expect(client.response_status).to eq(204) + + client.get_client_tokens(org_id) + expect(client.response_status).to eq(200) + expect(client.response_body['count'] + 1).to eq start_count + end + end + end + + describe 'public keys' do + let(:label) { 'Sandbox Key 1' } + context 'create' do + it 'sends data to API and sets response instance variables' do + rsa_key = OpenSSL::PKey::RSA.new(4096) + public_key = rsa_key.public_key.to_pem + message = 'This is the snippet used to verify a key pair in DPC.' + digest = OpenSSL::Digest.new('SHA256') + signature_binary = rsa_key.sign(digest, message) + snippet_signature = Base64.encode64(signature_binary) + + client.create_public_key( + org_id, + params: { label:, public_key:, snippet_signature: } + ) + + expect(client.response_status).to eq(200) + expect(client.response_body['label']).to eq label + end + end + + context 'list' do + it 'sends data to API and sets response instance variables' do + client.get_public_keys(org_id) + + expect(client.response_status).to eq(200) + expect(client.response_body['count']).to be > 0 + expect(client.response_body['entities'].first['label']).to eq(label) + end + end + + context 'delete' do + it 'sends data to API and sets response instance variables' do + # Need to get an existing id first + client.get_public_keys(org_id) + expect(client.response_status).to eq(200) + start_count = client.response_body['count'] + expect(start_count).to be > 0 + public_key_id = client.response_body['entities'].first['id'] + + client.delete_public_key(org_id, public_key_id) + + expect(client.response_status).to eq(200) + + client.get_public_keys(org_id) + expect(client.response_status).to eq(200) + expect(client.response_body['count'] + 1).to eq start_count + end + end + end + + describe 'ip address' do + context 'create' do + it 'sends data to API and sets response instance variables' do + client.create_ip_address(org_id, params: { ip_address: '136.226.19.87' }) + expect(client.response_status).to eq(200) + expect(client.response_body['id']).to_not be_blank + end + end + + context 'list' do + it 'sends data to API and sets response instance variables' do + client.get_ip_addresses(org_id) + expect(client.response_status).to eq(200) + expect(client.response_body['count']).to be > 0 + end + end + + context 'delete' do + it 'sends data to API and sets response instance variables' do + # Need to get an existing id first + client.get_ip_addresses(org_id) + expect(client.response_status).to eq(200) + start_count = client.response_body['count'] + expect(start_count).to be > 0 + ip_address_id = client.response_body['entities'].first['id'] + + client.delete_ip_address(org_id, ip_address_id) + + expect(client.response_status).to eq(204) + + client.get_ip_addresses(org_id) + expect(client.response_status).to eq(200) + expect(client.response_body['count'] + 1).to eq start_count + end + end + end + end +end diff --git a/engines/api_client/spec/spec_helper.rb b/engines/api_client/spec/spec_helper.rb index fb1b33ad55..65c284ba0c 100644 --- a/engines/api_client/spec/spec_helper.rb +++ b/engines/api_client/spec/spec_helper.rb @@ -3,15 +3,19 @@ require 'simplecov' require 'webmock/rspec' -SimpleCov.start do - track_files '**/{app,lib}/**/*.rb' - add_filter 'lib/api_client/version.rb' - add_filter %r{/dummy/} - SimpleCov.minimum_coverage 80 - SimpleCov.minimum_coverage_by_file 0 +unless ENV.fetch('SKIP_SIMPLE_COV', 'false') == 'true' + SimpleCov.start do + track_files '**/{app,lib}/**/*.rb' + add_filter 'lib/api_client/version.rb' + add_filter %r{/dummy/} + SimpleCov.minimum_coverage 80 + SimpleCov.minimum_coverage_by_file 0 + end end RSpec.configure do |config| + config.filter_run_excluding type: :integration + config.expect_with :rspec do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true end