From 028f4ee15e9aae002efe46e4b2c220ee75537a9e Mon Sep 17 00:00:00 2001 From: rushtong Date: Thu, 30 Apr 2026 18:02:03 -0400 Subject: [PATCH 01/16] feat: skip current smoke tests --- .github/workflows/smoke-tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/smoke-tests.yaml b/.github/workflows/smoke-tests.yaml index ef6ae037b1..d80a5f9f0e 100644 --- a/.github/workflows/smoke-tests.yaml +++ b/.github/workflows/smoke-tests.yaml @@ -10,6 +10,7 @@ on: jobs: smoke-tests: + if: false # This job will now be skipped every time runs-on: ubuntu-latest permissions: contents: 'read' From 39661c7fbbc27fdf3203c12d81521c92b1670486 Mon Sep 17 00:00:00 2001 From: rushtong Date: Thu, 30 Apr 2026 18:05:20 -0400 Subject: [PATCH 02/16] feat: add ci for integration tests --- .github/config/consent-ci.yaml | 80 +++++++++++ .github/config/seed-ci.sql | 176 +++++++++++++++++++++++ .github/workflows/integration-tests.yaml | 173 ++++++++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 .github/config/consent-ci.yaml create mode 100644 .github/config/seed-ci.sql create mode 100644 .github/workflows/integration-tests.yaml diff --git a/.github/config/consent-ci.yaml b/.github/config/consent-ci.yaml new file mode 100644 index 0000000000..896bd0c8c8 --- /dev/null +++ b/.github/config/consent-ci.yaml @@ -0,0 +1,80 @@ +server: + applicationContextPath: / + adminContextPath: /admin + applicationConnectors: + - type: http + port: 8080 + maxRequestHeaderSize: 32KiB + adminConnectors: + - type: http + port: 8081 + requestLog: + type: classic + appenders: + - type: console + +logging: + level: WARN + appenders: + - type: console + threshold: WARN + target: stdout + loggers: + "org.reflections.Reflections": ERROR + "org.apache.pdfbox": ERROR + "org.jdbi.v3": ERROR + +database: + driverClass: org.postgresql.Driver + user: consent + password: ci-password + url: jdbc:postgresql://localhost:5432/consent + initialSize: 5 + minSize: 5 + maxSize: 20 + validationQuery: SELECT 1 + +googleStore: + password: /tmp/ci-gcs-account.json + endpoint: http://localhost:9999/ + bucket: ci-bucket + +services: + localURL: http://localhost:8080/ + ontologyURL: http://localhost:9999/ + samUrl: http://localhost:9999/ + ecmUrl: http://localhost:9999/ + activateSupportNotifications: false + timeoutSeconds: 10 + poolSize: 1 + cacheExpireMinutes: 0 + +mailConfiguration: + activateEmailNotifications: false + googleAccount: ci@example.com + sendGridApiKey: ci-key + sendGridStatusUrl: http://localhost:9999/ + +freeMarkerConfiguration: + templateDirectory: /freemarker + defaultEncoding: UTF-8 + +googleAuthentication: + clientId: ci-client-id + +storeOntology: + bucketSubdirectory: ontology + configurationFileName: /configuration + +elasticSearch: + servers: + - localhost + indexName: ontology-ci + datasetIndexName: dataset-ci + +oidcConfiguration: + clientId: ci-client-id + addClientIdToScope: false + extraAuthParams: "" + authorityEndpoint: "http://localhost:9999/" + diff --git a/.github/config/seed-ci.sql b/.github/config/seed-ci.sql new file mode 100644 index 0000000000..3b6547bc58 --- /dev/null +++ b/.github/config/seed-ci.sql @@ -0,0 +1,176 @@ +-- ============================================================================= +-- CI Integration-Test Seed Data +-- ============================================================================= +-- This file runs AFTER Liquibase migrations have applied the full schema. +-- It inserts the minimum set of synthetic reference objects that integration +-- tests need in order to exercise each user-role path. +-- +-- HOW TO EXTEND +-- Add new INSERT blocks in the relevant section below. Each section is +-- self-contained and idempotent: re-running this script against a database +-- that already contains these rows is safe (nothing will be duplicated). +-- +-- SYNTHETIC DATA ONLY +-- Do not add real email addresses, names, tokens, or credentials. +-- All example.com addresses are RFC-5737 reserved and will never resolve. +-- ============================================================================= + + +-- =========================================================================== +-- 1. USERS +-- One representative user per application role. +-- Add more rows here when you need additional actors in your tests. +-- =========================================================================== + +INSERT INTO users (email, display_name, create_date, email_preference) +VALUES + -- System-level roles + ('ci-admin@example.com', 'CI Admin', NOW(), false), + ('ci-signing-official@example.com', 'CI Signing Official', NOW(), false), + ('ci-it-director@example.com', 'CI IT Director', NOW(), false), + ('ci-data-submitter@example.com', 'CI Data Submitter', NOW(), false), + ('ci-researcher@example.com', 'CI Researcher', NOW(), false), + + -- DAC-scoped roles (chair/member assignment happens in section 5) + ('ci-chair@example.com', 'CI DAC Chair', NOW(), false), + ('ci-member@example.com', 'CI DAC Member', NOW(), false) +ON CONFLICT (email) DO NOTHING; + + +-- =========================================================================== +-- 2. INSTITUTIONS +-- A single test institution linked to the CI admin as creator. +-- Add more rows here when your tests require multiple institutions. +-- =========================================================================== + +INSERT INTO institution (institution_name, it_director_name, it_director_email, create_user, create_date) +SELECT + 'CI Test Institution', + 'CI IT Director', + 'ci-it-director@example.com', + u.user_id, + NOW() +FROM users u +WHERE u.email = 'ci-admin@example.com' +ON CONFLICT (institution_name) DO NOTHING; + +-- Link the researcher and signing official to the test institution so that +-- library-card and signing-official workflows have valid FK references. +UPDATE users +SET institution_id = ( + SELECT institution_id FROM institution + WHERE institution_name = 'CI Test Institution' +) +WHERE email IN ( + 'ci-researcher@example.com', + 'ci-signing-official@example.com' +) + AND institution_id IS NULL; + + +-- =========================================================================== +-- 3. USER ROLES (non-DAC) +-- Associates each user with their primary application-level role. +-- Use separate INSERT blocks for additional role assignments. +-- =========================================================================== + +INSERT INTO user_role (role_id, user_id) +SELECT r.role_id, u.user_id +FROM roles r +JOIN users u ON TRUE +WHERE (r.name = 'Admin' AND u.email = 'ci-admin@example.com') + OR (r.name = 'SigningOfficial' AND u.email = 'ci-signing-official@example.com') + OR (r.name = 'ITDirector' AND u.email = 'ci-it-director@example.com') + OR (r.name = 'DataSubmitter' AND u.email = 'ci-data-submitter@example.com') + OR (r.name = 'Researcher' AND u.email = 'ci-researcher@example.com') + -- skip rows that already exist + AND NOT EXISTS ( + SELECT 1 FROM user_role ur2 + WHERE ur2.user_id = u.user_id AND ur2.role_id = r.role_id AND ur2.dac_id IS NULL + ); + + +-- =========================================================================== +-- 4. DAC +-- A single test DAC. Repeated runs are safe: the INSERT is skipped when +-- a DAC with the same name already exists. +-- An audit row (action = CREATE) is written atomically alongside the DAC. +-- =========================================================================== + +DO $$ +DECLARE + v_admin_id bigint; + v_dac_id bigint; +BEGIN + SELECT user_id INTO v_admin_id FROM users WHERE email = 'ci-admin@example.com'; + + -- Insert the DAC only if it does not already exist. + SELECT dac_id INTO v_dac_id FROM dac WHERE name = 'CI Test DAC'; + + IF v_dac_id IS NULL THEN + INSERT INTO dac (name, description, create_date, deleted) + VALUES ('CI Test DAC', 'Test DAC for CI integration tests', NOW(), false) + RETURNING dac_id INTO v_dac_id; + + INSERT INTO dac_audit (dac_id, user_id, action, action_date) + VALUES (v_dac_id, v_admin_id, 'CREATE', NOW()); + END IF; +END $$; + + +-- =========================================================================== +-- 5. DAC MEMBER ASSIGNMENTS +-- Assigns the CI chair and CI member to the test DAC. +-- Extend this section to add more DAC-scoped role assignments. +-- =========================================================================== + +INSERT INTO user_role (role_id, user_id, dac_id) +SELECT r.role_id, u.user_id, d.dac_id +FROM roles r +JOIN users u ON TRUE +JOIN dac d ON d.name = 'CI Test DAC' +WHERE (r.name = 'Chairperson' AND u.email = 'ci-chair@example.com') + OR (r.name = 'Member' AND u.email = 'ci-member@example.com') + AND NOT EXISTS ( + SELECT 1 FROM user_role ur2 + WHERE ur2.user_id = u.user_id AND ur2.role_id = r.role_id AND ur2.dac_id = d.dac_id + ); + +-- Write DAC audit entries for the member additions. +INSERT INTO dac_audit (dac_id, user_id, affected_user_id, role_id, action, action_date) +SELECT + d.dac_id, + admin_u.user_id, -- actor: CI admin + u.user_id, -- subject: user being added + r.role_id, + 'ADD', + NOW() +FROM roles r +JOIN users u ON TRUE +JOIN dac d ON d.name = 'CI Test DAC' +JOIN users admin_u ON admin_u.email = 'ci-admin@example.com' +WHERE (r.name = 'Chairperson' AND u.email = 'ci-chair@example.com') + OR (r.name = 'Member' AND u.email = 'ci-member@example.com') + AND NOT EXISTS ( + SELECT 1 FROM dac_audit da + WHERE da.dac_id = d.dac_id + AND da.affected_user_id = u.user_id + AND da.action = 'ADD' + ); + + +-- =========================================================================== +-- ADD CUSTOM APPLICATION DATA BELOW +-- =========================================================================== +-- Examples of what you might add: +-- +-- * Datasets and dataset properties +-- * Data Access Requests (DAR collections) +-- * Library Cards linked to ci-researcher@example.com +-- * Data Access Agreements (DAA) linked to the CI Test DAC +-- * Feature flags +-- +-- Follow the same idempotency pattern: use ON CONFLICT DO NOTHING or +-- WHERE NOT EXISTS so that repeated runs against the same database are safe. +-- =========================================================================== + diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml new file mode 100644 index 0000000000..e0c3943322 --- /dev/null +++ b/.github/workflows/integration-tests.yaml @@ -0,0 +1,173 @@ +name: Integration Tests + +on: + push: + branches: + - develop + pull_request: + branches: + - develop + workflow_dispatch: + inputs: + sql-file: + description: >- + Path (relative to repo root) to a SQL dump file used to seed the + database. Overrides the DB_SEED_SQL_FILE repository variable when + Leave blank to use .github/config/seed-ci.sql (the default + synthetic seed), or set DB_SEED_SQL_FILE at the repo level to + override it persistently without changing the workflow file. + required: false + default: "" + +jobs: + integration-tests: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: consent + POSTGRES_USER: consent + POSTGRES_PASSWORD: ci-password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U consent -d consent" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + elasticsearch: + image: elasticsearch:9.3.0 + env: + discovery.type: single-node + xpack.security.enabled: "false" + cluster.routing.allocation.disk.threshold_enabled: "false" + ports: + - 9200:9200 + options: >- + --health-cmd "curl -sf http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=30s" + --health-interval 30s + --health-timeout 15s + --health-retries 10 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 25 + cache: maven + + - name: Build application jar + run: | + mvn clean package -Dmaven.test.skip=true --batch-mode --no-transfer-progress + + # Resolve which SQL file to seed the database with. + # Priority (highest → lowest): + # 1. workflow_dispatch input (sql-file) + # 2. DB_SEED_SQL_FILE repository/environment variable + # 3. .github/config/seed-ci.sql – the default synthetic seed file + # 4. Skip seeding – Liquibase initialises a clean schema only. + - name: Resolve SQL seed file + id: resolve-sql + env: + DISPATCH_FILE: ${{ inputs.sql-file }} + REPO_SQL_FILE: ${{ vars.DB_SEED_SQL_FILE }} + run: | + if [[ -n "$DISPATCH_FILE" && -f "$DISPATCH_FILE" ]]; then + echo "sql-file=$DISPATCH_FILE" >> "$GITHUB_OUTPUT" + elif [[ -n "$REPO_SQL_FILE" && -f "$REPO_SQL_FILE" ]]; then + echo "sql-file=$REPO_SQL_FILE" >> "$GITHUB_OUTPUT" + elif [[ -f ".github/config/seed-ci.sql" ]]; then + echo "sql-file=.github/config/seed-ci.sql" >> "$GITHUB_OUTPUT" + else + echo "sql-file=" >> "$GITHUB_OUTPUT" + echo "No SQL seed file found; Liquibase will initialise a clean schema." + fi + + - name: Seed database + if: steps.resolve-sql.outputs.sql-file != '' + env: + PGPASSWORD: ci-password + run: | + psql -h localhost -U consent -d consent \ + -f "${{ steps.resolve-sql.outputs.sql-file }}" + + # Write a stub GCS service-account JSON so the app can start without + # real credentials. All GCS operations will fail at runtime, which is + # acceptable for integration tests that only exercise HTTP/DB paths. + - name: Write stub GCS service-account + run: | + cat > /tmp/ci-gcs-account.json <<'SACEOF' + { + "type": "service_account", + "project_id": "ci-project", + "private_key_id": "ci-key-id", + "private_key": "", + "client_email": "ci@ci-project.iam.gserviceaccount.com", + "client_id": "000000000000000000000", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } + SACEOF + + - name: Start application + run: | + java \ + -classpath "target/classes:$(find target/lib -name '*.jar' | tr '\n' ':')" \ + org.broadinstitute.consent.http.ConsentApplication \ + server .github/config/consent-ci.yaml \ + > /tmp/consent-app.log 2>&1 & + echo "APP_PID=$!" >> "$GITHUB_ENV" + + - name: Wait for application to be healthy + timeout-minutes: 3 + run: | + echo "Waiting for consent app on port 8080..." + for i in $(seq 1 36); do + if curl -sf http://localhost:8080/status > /dev/null 2>&1; then + echo "Application is up after ~$((i * 5)) seconds." + exit 0 + fi + echo " Attempt $i/36 – not ready yet, sleeping 5s..." + sleep 5 + done + echo "Application failed to start within 3 minutes." + echo "=== app log ===" + cat /tmp/consent-app.log + exit 1 + + - name: Run integration tests + run: | + mvn test -P integration-tests \ + -DbaseUrl="http://localhost:8080/" \ + --batch-mode + + - name: Stop application + if: always() + run: | + if [[ -n "$APP_PID" ]]; then + kill "$APP_PID" || true + fi + + - name: Upload application log + if: always() + uses: actions/upload-artifact@v4 + with: + name: app-log + path: /tmp/consent-app.log + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: integration-test-reports + path: target/surefire-reports + From 1edced50fdfab52d7b01f83f1c420b5ceba67fb1 Mon Sep 17 00:00:00 2001 From: rushtong Date: Thu, 30 Apr 2026 18:10:19 -0400 Subject: [PATCH 03/16] feat: fully disable smoke tests --- .github/workflows/smoke-tests.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smoke-tests.yaml b/.github/workflows/smoke-tests.yaml index d80a5f9f0e..688b4300a4 100644 --- a/.github/workflows/smoke-tests.yaml +++ b/.github/workflows/smoke-tests.yaml @@ -63,7 +63,7 @@ jobs: upload-test-reports: needs: [smoke-tests] - if: always() + if: false #always() permissions: contents: 'read' id-token: 'write' @@ -77,7 +77,7 @@ jobs: subuuid: ${{ github.run_id }} report-workflow: - if: github.ref == 'refs/heads/develop' + if: false #github.ref == 'refs/heads/develop' uses: broadinstitute/sherlock/.github/workflows/client-report-workflow.yaml@main with: relates-to-chart-releases: 'consent-dev' From f82c7033f2b8a818f458cdf15430ab6116b210e3 Mon Sep 17 00:00:00 2001 From: rushtong Date: Thu, 30 Apr 2026 18:23:47 -0400 Subject: [PATCH 04/16] feat: add report workflow to keep sherlock up to date --- .github/workflows/integration-tests.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index e0c3943322..39e83c5e5e 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -170,4 +170,12 @@ jobs: with: name: integration-test-reports path: target/surefire-reports - + report-workflow: + if: github.ref == 'refs/heads/develop' + uses: broadinstitute/sherlock/.github/workflows/client-report-workflow.yaml@main + with: + relates-to-chart-releases: 'consent-dev' + notify-slack-channels-upon-workflow-failure: ${{ vars.SLACK_NOTIFICATION_CHANNELS }} + notify-slack-channels-upon-workflow-retry: ${{ vars.SLACK_NOTIFICATION_CHANNELS }} + permissions: + id-token: write From b97066dd9be8484fdab6bf6a3ab740e1cdd99a01 Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 07:12:18 -0400 Subject: [PATCH 05/16] feat: address sonar hotspot --- .github/workflows/integration-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index 39e83c5e5e..d91d1f44f6 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -172,7 +172,7 @@ jobs: path: target/surefire-reports report-workflow: if: github.ref == 'refs/heads/develop' - uses: broadinstitute/sherlock/.github/workflows/client-report-workflow.yaml@main + uses: broadinstitute/sherlock/.github/workflows/client-report-workflow.yaml@26b6fc02e7a5bab765d9237d677d184f413fbc85 with: relates-to-chart-releases: 'consent-dev' notify-slack-channels-upon-workflow-failure: ${{ vars.SLACK_NOTIFICATION_CHANNELS }} From a7af95f5789027701c07d15301c1371ba3d8358a Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 07:13:32 -0400 Subject: [PATCH 06/16] feat: script for running local tests --- scripts/run-integration-tests.sh | 185 +++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100755 scripts/run-integration-tests.sh diff --git a/scripts/run-integration-tests.sh b/scripts/run-integration-tests.sh new file mode 100755 index 0000000000..cbc8ad1907 --- /dev/null +++ b/scripts/run-integration-tests.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# ============================================================================= +# run-integration-tests.sh +# +# Runs the integration-test suite locally using the same services, config, and +# seed data that the GitHub Actions workflow uses. +# +# USAGE +# ./scripts/run-integration-tests.sh [OPTIONS] +# +# OPTIONS +# --skip-build Skip `mvn clean package`; use an existing target/ jar. +# --sql-file Path (relative to repo root) to a SQL seed file. +# Defaults to .github/config/seed-ci.sql. +# --base-url Override the baseUrl passed to integration tests. +# Defaults to http://localhost:8080/ +# -h, --help Print this message and exit. +# +# REQUIREMENTS +# docker, mvn, java, psql must all be on PATH. +# ============================================================================= + +set -euo pipefail + +# ── Defaults ───────────────────────────────────────────────────────────────── +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SKIP_BUILD=false +SQL_FILE="${REPO_ROOT}/.github/config/seed-ci.sql" +BASE_URL="http://localhost:8080/" +APP_LOG="/tmp/consent-app.log" +GCS_STUB="/tmp/ci-gcs-account.json" +APP_PID="" + +POSTGRES_CONTAINER="consent-ci-postgres" +ELASTIC_CONTAINER="consent-ci-elastic" +DB_NAME="consent" +DB_USER="consent" +DB_PASS="ci-password" + +# ── Argument parsing ────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-build) SKIP_BUILD=true; shift ;; + --sql-file) SQL_FILE="${REPO_ROOT}/$2"; shift 2 ;; + --base-url) BASE_URL="$2"; shift 2 ;; + -h|--help) + sed -n '/^# USAGE/,/^# REQUIREMENTS/p' "$0" | sed 's/^# \{0,2\}//' + exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +# ── Cleanup on exit ─────────────────────────────────────────────────────────── +cleanup() { + echo "" + echo "── Cleanup ──────────────────────────────────────────────────────────" + if [[ -n "$APP_PID" ]] && kill -0 "$APP_PID" 2>/dev/null; then + echo "Stopping application (PID $APP_PID)..." + kill "$APP_PID" || true + fi + echo "Stopping containers..." + docker rm -f "$POSTGRES_CONTAINER" "$ELASTIC_CONTAINER" 2>/dev/null || true + echo "Done." +} +trap cleanup EXIT + +cd "$REPO_ROOT" + +echo "══════════════════════════════════════════════════════════════════════" +echo " Consent Integration Tests – local run" +echo "══════════════════════════════════════════════════════════════════════" + +# ── 1. Build ────────────────────────────────────────────────────────────────── +if [[ "$SKIP_BUILD" == "true" ]]; then + echo "── Step 1/7: Build (skipped) ─────────────────────────────────────────" +else + echo "── Step 1/7: Build ───────────────────────────────────────────────────" + mvn clean package -Dmaven.test.skip=true --batch-mode --no-transfer-progress +fi + +# ── 2. Start PostgreSQL ─────────────────────────────────────────────────────── +echo "── Step 2/7: Start PostgreSQL ────────────────────────────────────────" +docker rm -f "$POSTGRES_CONTAINER" 2>/dev/null || true +docker run -d \ + --name "$POSTGRES_CONTAINER" \ + -e POSTGRES_DB="$DB_NAME" \ + -e POSTGRES_USER="$DB_USER" \ + -e POSTGRES_PASSWORD="$DB_PASS" \ + -p 5432:5432 \ + postgres:16-alpine + +echo -n " Waiting for PostgreSQL..." +for i in $(seq 1 30); do + if docker exec "$POSTGRES_CONTAINER" pg_isready -U "$DB_USER" -d "$DB_NAME" &>/dev/null; then + echo " ready." + break + fi + echo -n "." + sleep 2 + if [[ $i -eq 30 ]]; then + echo " timed out." >&2; exit 1 + fi +done + +# ── 3. Start Elasticsearch ──────────────────────────────────────────────────── +echo "── Step 3/7: Start Elasticsearch ────────────────────────────────────" +docker rm -f "$ELASTIC_CONTAINER" 2>/dev/null || true +docker run -d \ + --name "$ELASTIC_CONTAINER" \ + -e "discovery.type=single-node" \ + -e "xpack.security.enabled=false" \ + -e "cluster.routing.allocation.disk.threshold_enabled=false" \ + -p 9200:9200 \ + elasticsearch:9.3.0 + +echo -n " Waiting for Elasticsearch..." +for i in $(seq 1 30); do + if curl -sf "http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s" &>/dev/null; then + echo " ready." + break + fi + echo -n "." + sleep 5 + if [[ $i -eq 30 ]]; then + echo " timed out." >&2; exit 1 + fi +done + +# ── 4. Seed database ────────────────────────────────────────────────────────── +echo "── Step 4/7: Seed database ───────────────────────────────────────────" +if [[ -f "$SQL_FILE" ]]; then + echo " Using: $SQL_FILE" + PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -f "$SQL_FILE" +else + echo " No seed file found at '$SQL_FILE'; skipping (Liquibase will init schema)." +fi + +# ── 5. Write stub GCS credentials ───────────────────────────────────────────── +echo "── Step 5/7: Write stub GCS service-account ──────────────────────────" +cat > "$GCS_STUB" <<'EOF' +{ + "type": "service_account", + "project_id": "ci-project", + "private_key_id": "ci-key-id", + "private_key": "", + "client_email": "ci@ci-project.iam.gserviceaccount.com", + "client_id": "000000000000000000000", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" +} +EOF + +# ── 6. Start application ────────────────────────────────────────────────────── +echo "── Step 6/7: Start application ───────────────────────────────────────" +echo " Log: $APP_LOG" +java \ + -classpath "target/classes:$(find target/lib -name '*.jar' | tr '\n' ':')" \ + org.broadinstitute.consent.http.ConsentApplication \ + server .github/config/consent-ci.yaml \ + > "$APP_LOG" 2>&1 & +APP_PID=$! + +echo -n " Waiting for application..." +for i in $(seq 1 36); do + if curl -sf "http://localhost:8080/status" &>/dev/null; then + echo " ready (~$((i * 5))s)." + break + fi + echo -n "." + sleep 5 + if [[ $i -eq 36 ]]; then + echo " timed out." >&2 + echo "=== application log ===" >&2 + cat "$APP_LOG" >&2 + exit 1 + fi +done + +# ── 7. Run integration tests ────────────────────────────────────────────────── +echo "── Step 7/7: Run integration tests ──────────────────────────────────" +echo " baseUrl: $BASE_URL" +mvn test -P integration-tests \ + -DbaseUrl="$BASE_URL" \ + --batch-mode + From 9f39d1786ca1fdbdad0d30efdc762182746b8573 Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 07:18:42 -0400 Subject: [PATCH 07/16] feat: update dev notes --- DEVNOTES.md | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/DEVNOTES.md b/DEVNOTES.md index d0290deb53..85a272d19a 100644 --- a/DEVNOTES.md +++ b/DEVNOTES.md @@ -151,4 +151,74 @@ e.g. ```$ export OSS_INDEX_PASSWORD=``` Run the dependency checker: -```$ mvn org.owasp:dependency-check-maven:check``` \ No newline at end of file +```$ mvn org.owasp:dependency-check-maven:check``` + +## Integration Testing + +Integration tests live in `src/test/java/**/integration/` and are run with the +`integration-tests` Maven profile. They make real HTTP calls against a running +instance of the application, so a live server and its backing services must be +available before the tests execute. + +#### How they run in CI + +The GitHub Actions workflow at `.github/workflows/integration-tests.yaml` +handles everything automatically on every push/PR to `develop`: + +1. **PostgreSQL 16** and **Elasticsearch 9** are started as service containers. +2. The application jar is built with `mvn clean package`. +3. A SQL seed file is loaded into Postgres to provide baseline test data + (see [SQL seed file priority](#sql-seed-file-priority) below). +4. The application is started with the CI config at + `.github/config/consent-ci.yaml` and the workflow waits for `GET /status` + to return 200. +5. `mvn test -P integration-tests -DbaseUrl=http://localhost:8080/` is run. +6. Test reports are uploaded as a workflow artifact (`integration-test-reports`) + and the application log as `app-log`. + +#### SQL seed file priority + +The workflow resolves which SQL file populates the database using this +precedence (highest to lowest): + +| Priority | Source | +|---|---| +| 1 | `sql-file` input on a manual `workflow_dispatch` trigger | +| 2 | `DB_SEED_SQL_FILE` repository/environment variable (set in **Settings → Variables → Actions**) | +| 3 | `.github/config/seed-ci.sql` — the default synthetic seed checked into the repo | +| 4 | *(nothing)* — Liquibase initialises a clean schema only | + +The seed file at `.github/config/seed-ci.sql` contains one synthetic user per +application role, a test institution, and a test DAC. It is idempotent and +safe to run repeatedly. Add new rows in the clearly marked sections at the +bottom of that file; follow the `ON CONFLICT DO NOTHING` / `WHERE NOT EXISTS` +pattern already used there. + +#### Running integration tests locally + +A convenience script mirrors the CI workflow exactly: + +```bash +# Full run: build → start services → seed DB → start app → test → cleanup +./scripts/run-integration-tests.sh + +# Skip Maven build when the jar is already up-to-date +./scripts/run-integration-tests.sh --skip-build + +# Use a different SQL seed file (e.g. a recent DB dump) +./scripts/run-integration-tests.sh --sql-file config/consent-recent.sql + +# Run the tests against an already-running environment instead +./scripts/run-integration-tests.sh --base-url https://consent.dsde-dev.broadinstitute.org/ +``` + +The script requires `docker`, `mvn`, `java`, and `psql` on your `PATH`. +On exit (pass or fail) it automatically stops the application process and +removes the Docker containers it started, so nothing is left running. + +To run the tests manually against an existing environment, pass a custom base +URL directly: + +```bash +mvn test -P integration-tests -DbaseUrl=https://consent.dsde-dev.broadinstitute.org/ +``` From c5c632f79e2608dac6a79bf826366041d40b7200 Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 08:09:23 -0400 Subject: [PATCH 08/16] feat: update seed operation order --- .github/workflows/integration-tests.yaml | 18 ++++++++------- scripts/run-integration-tests.sh | 28 +++++++++++++----------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index d91d1f44f6..e524af7b53 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -92,14 +92,6 @@ jobs: echo "No SQL seed file found; Liquibase will initialise a clean schema." fi - - name: Seed database - if: steps.resolve-sql.outputs.sql-file != '' - env: - PGPASSWORD: ci-password - run: | - psql -h localhost -U consent -d consent \ - -f "${{ steps.resolve-sql.outputs.sql-file }}" - # Write a stub GCS service-account JSON so the app can start without # real credentials. All GCS operations will fail at runtime, which is # acceptable for integration tests that only exercise HTTP/DB paths. @@ -144,6 +136,16 @@ jobs: cat /tmp/consent-app.log exit 1 + # Seed runs after the app starts so that Liquibase has already created + # the full schema before any INSERT statements are executed. + - name: Seed database + if: steps.resolve-sql.outputs.sql-file != '' + env: + PGPASSWORD: ci-password + run: | + psql -h localhost -U consent -d consent \ + -f "${{ steps.resolve-sql.outputs.sql-file }}" + - name: Run integration tests run: | mvn test -P integration-tests \ diff --git a/scripts/run-integration-tests.sh b/scripts/run-integration-tests.sh index cbc8ad1907..12ee2f7d96 100755 --- a/scripts/run-integration-tests.sh +++ b/scripts/run-integration-tests.sh @@ -126,17 +126,8 @@ for i in $(seq 1 30); do fi done -# ── 4. Seed database ────────────────────────────────────────────────────────── -echo "── Step 4/7: Seed database ───────────────────────────────────────────" -if [[ -f "$SQL_FILE" ]]; then - echo " Using: $SQL_FILE" - PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -f "$SQL_FILE" -else - echo " No seed file found at '$SQL_FILE'; skipping (Liquibase will init schema)." -fi - -# ── 5. Write stub GCS credentials ───────────────────────────────────────────── -echo "── Step 5/7: Write stub GCS service-account ──────────────────────────" +# ── 4. Write stub GCS credentials ───────────────────────────────────────────── +echo "── Step 4/7: Write stub GCS service-account ──────────────────────────" cat > "$GCS_STUB" <<'EOF' { "type": "service_account", @@ -150,8 +141,8 @@ cat > "$GCS_STUB" <<'EOF' } EOF -# ── 6. Start application ────────────────────────────────────────────────────── -echo "── Step 6/7: Start application ───────────────────────────────────────" +# ── 5. Start application ────────────────────────────────────────────────────── +echo "── Step 5/7: Start application ───────────────────────────────────────" echo " Log: $APP_LOG" java \ -classpath "target/classes:$(find target/lib -name '*.jar' | tr '\n' ':')" \ @@ -176,6 +167,17 @@ for i in $(seq 1 36); do fi done +# ── 6. Seed database ────────────────────────────────────────────────────────── +# Runs after the app starts so that Liquibase has already created the full +# schema before any INSERT statements are executed. +echo "── Step 6/7: Seed database ───────────────────────────────────────────" +if [[ -f "$SQL_FILE" ]]; then + echo " Using: $SQL_FILE" + PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -f "$SQL_FILE" +else + echo " No seed file found at '$SQL_FILE'; skipping (Liquibase will init schema)." +fi + # ── 7. Run integration tests ────────────────────────────────────────────────── echo "── Step 7/7: Run integration tests ──────────────────────────────────" echo " baseUrl: $BASE_URL" From 41486bd4ef85fb5fb53e8afe4d96c0c07c913d12 Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 17:04:24 -0400 Subject: [PATCH 09/16] feat: run all smoke tests during normal test phase as container tests --- .github/config/seed-ci.sql | 176 -------------- .github/workflows/integration-tests.yaml | 183 --------------- .github/workflows/smoke-tests.yaml | 87 ------- pom.xml | 46 ---- scripts/run-integration-tests.sh | 187 --------------- .../consent/integration/ContainerTests.java | 216 ++++++++++++++++++ .../integration/IntegrationTestHelper.java | 46 ---- .../consent/integration/README.md | 56 ++++- .../integration/status/StatusTests.java | 50 ++-- .../consent/integration/user/UserTests.java | 114 +++++++++ .../test/resources}/consent-ci.yaml | 4 +- 11 files changed, 399 insertions(+), 766 deletions(-) delete mode 100644 .github/config/seed-ci.sql delete mode 100644 .github/workflows/integration-tests.yaml delete mode 100644 .github/workflows/smoke-tests.yaml delete mode 100755 scripts/run-integration-tests.sh create mode 100644 src/test/java/org/broadinstitute/consent/integration/ContainerTests.java delete mode 100644 src/test/java/org/broadinstitute/consent/integration/IntegrationTestHelper.java create mode 100644 src/test/java/org/broadinstitute/consent/integration/user/UserTests.java rename {.github/config => src/test/resources}/consent-ci.yaml (97%) diff --git a/.github/config/seed-ci.sql b/.github/config/seed-ci.sql deleted file mode 100644 index 3b6547bc58..0000000000 --- a/.github/config/seed-ci.sql +++ /dev/null @@ -1,176 +0,0 @@ --- ============================================================================= --- CI Integration-Test Seed Data --- ============================================================================= --- This file runs AFTER Liquibase migrations have applied the full schema. --- It inserts the minimum set of synthetic reference objects that integration --- tests need in order to exercise each user-role path. --- --- HOW TO EXTEND --- Add new INSERT blocks in the relevant section below. Each section is --- self-contained and idempotent: re-running this script against a database --- that already contains these rows is safe (nothing will be duplicated). --- --- SYNTHETIC DATA ONLY --- Do not add real email addresses, names, tokens, or credentials. --- All example.com addresses are RFC-5737 reserved and will never resolve. --- ============================================================================= - - --- =========================================================================== --- 1. USERS --- One representative user per application role. --- Add more rows here when you need additional actors in your tests. --- =========================================================================== - -INSERT INTO users (email, display_name, create_date, email_preference) -VALUES - -- System-level roles - ('ci-admin@example.com', 'CI Admin', NOW(), false), - ('ci-signing-official@example.com', 'CI Signing Official', NOW(), false), - ('ci-it-director@example.com', 'CI IT Director', NOW(), false), - ('ci-data-submitter@example.com', 'CI Data Submitter', NOW(), false), - ('ci-researcher@example.com', 'CI Researcher', NOW(), false), - - -- DAC-scoped roles (chair/member assignment happens in section 5) - ('ci-chair@example.com', 'CI DAC Chair', NOW(), false), - ('ci-member@example.com', 'CI DAC Member', NOW(), false) -ON CONFLICT (email) DO NOTHING; - - --- =========================================================================== --- 2. INSTITUTIONS --- A single test institution linked to the CI admin as creator. --- Add more rows here when your tests require multiple institutions. --- =========================================================================== - -INSERT INTO institution (institution_name, it_director_name, it_director_email, create_user, create_date) -SELECT - 'CI Test Institution', - 'CI IT Director', - 'ci-it-director@example.com', - u.user_id, - NOW() -FROM users u -WHERE u.email = 'ci-admin@example.com' -ON CONFLICT (institution_name) DO NOTHING; - --- Link the researcher and signing official to the test institution so that --- library-card and signing-official workflows have valid FK references. -UPDATE users -SET institution_id = ( - SELECT institution_id FROM institution - WHERE institution_name = 'CI Test Institution' -) -WHERE email IN ( - 'ci-researcher@example.com', - 'ci-signing-official@example.com' -) - AND institution_id IS NULL; - - --- =========================================================================== --- 3. USER ROLES (non-DAC) --- Associates each user with their primary application-level role. --- Use separate INSERT blocks for additional role assignments. --- =========================================================================== - -INSERT INTO user_role (role_id, user_id) -SELECT r.role_id, u.user_id -FROM roles r -JOIN users u ON TRUE -WHERE (r.name = 'Admin' AND u.email = 'ci-admin@example.com') - OR (r.name = 'SigningOfficial' AND u.email = 'ci-signing-official@example.com') - OR (r.name = 'ITDirector' AND u.email = 'ci-it-director@example.com') - OR (r.name = 'DataSubmitter' AND u.email = 'ci-data-submitter@example.com') - OR (r.name = 'Researcher' AND u.email = 'ci-researcher@example.com') - -- skip rows that already exist - AND NOT EXISTS ( - SELECT 1 FROM user_role ur2 - WHERE ur2.user_id = u.user_id AND ur2.role_id = r.role_id AND ur2.dac_id IS NULL - ); - - --- =========================================================================== --- 4. DAC --- A single test DAC. Repeated runs are safe: the INSERT is skipped when --- a DAC with the same name already exists. --- An audit row (action = CREATE) is written atomically alongside the DAC. --- =========================================================================== - -DO $$ -DECLARE - v_admin_id bigint; - v_dac_id bigint; -BEGIN - SELECT user_id INTO v_admin_id FROM users WHERE email = 'ci-admin@example.com'; - - -- Insert the DAC only if it does not already exist. - SELECT dac_id INTO v_dac_id FROM dac WHERE name = 'CI Test DAC'; - - IF v_dac_id IS NULL THEN - INSERT INTO dac (name, description, create_date, deleted) - VALUES ('CI Test DAC', 'Test DAC for CI integration tests', NOW(), false) - RETURNING dac_id INTO v_dac_id; - - INSERT INTO dac_audit (dac_id, user_id, action, action_date) - VALUES (v_dac_id, v_admin_id, 'CREATE', NOW()); - END IF; -END $$; - - --- =========================================================================== --- 5. DAC MEMBER ASSIGNMENTS --- Assigns the CI chair and CI member to the test DAC. --- Extend this section to add more DAC-scoped role assignments. --- =========================================================================== - -INSERT INTO user_role (role_id, user_id, dac_id) -SELECT r.role_id, u.user_id, d.dac_id -FROM roles r -JOIN users u ON TRUE -JOIN dac d ON d.name = 'CI Test DAC' -WHERE (r.name = 'Chairperson' AND u.email = 'ci-chair@example.com') - OR (r.name = 'Member' AND u.email = 'ci-member@example.com') - AND NOT EXISTS ( - SELECT 1 FROM user_role ur2 - WHERE ur2.user_id = u.user_id AND ur2.role_id = r.role_id AND ur2.dac_id = d.dac_id - ); - --- Write DAC audit entries for the member additions. -INSERT INTO dac_audit (dac_id, user_id, affected_user_id, role_id, action, action_date) -SELECT - d.dac_id, - admin_u.user_id, -- actor: CI admin - u.user_id, -- subject: user being added - r.role_id, - 'ADD', - NOW() -FROM roles r -JOIN users u ON TRUE -JOIN dac d ON d.name = 'CI Test DAC' -JOIN users admin_u ON admin_u.email = 'ci-admin@example.com' -WHERE (r.name = 'Chairperson' AND u.email = 'ci-chair@example.com') - OR (r.name = 'Member' AND u.email = 'ci-member@example.com') - AND NOT EXISTS ( - SELECT 1 FROM dac_audit da - WHERE da.dac_id = d.dac_id - AND da.affected_user_id = u.user_id - AND da.action = 'ADD' - ); - - --- =========================================================================== --- ADD CUSTOM APPLICATION DATA BELOW --- =========================================================================== --- Examples of what you might add: --- --- * Datasets and dataset properties --- * Data Access Requests (DAR collections) --- * Library Cards linked to ci-researcher@example.com --- * Data Access Agreements (DAA) linked to the CI Test DAC --- * Feature flags --- --- Follow the same idempotency pattern: use ON CONFLICT DO NOTHING or --- WHERE NOT EXISTS so that repeated runs against the same database are safe. --- =========================================================================== - diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml deleted file mode 100644 index e524af7b53..0000000000 --- a/.github/workflows/integration-tests.yaml +++ /dev/null @@ -1,183 +0,0 @@ -name: Integration Tests - -on: - push: - branches: - - develop - pull_request: - branches: - - develop - workflow_dispatch: - inputs: - sql-file: - description: >- - Path (relative to repo root) to a SQL dump file used to seed the - database. Overrides the DB_SEED_SQL_FILE repository variable when - Leave blank to use .github/config/seed-ci.sql (the default - synthetic seed), or set DB_SEED_SQL_FILE at the repo level to - override it persistently without changing the workflow file. - required: false - default: "" - -jobs: - integration-tests: - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_DB: consent - POSTGRES_USER: consent - POSTGRES_PASSWORD: ci-password - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U consent -d consent" - --health-interval 10s - --health-timeout 5s - --health-retries 10 - - elasticsearch: - image: elasticsearch:9.3.0 - env: - discovery.type: single-node - xpack.security.enabled: "false" - cluster.routing.allocation.disk.threshold_enabled: "false" - ports: - - 9200:9200 - options: >- - --health-cmd "curl -sf http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=30s" - --health-interval 30s - --health-timeout 15s - --health-retries 10 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Set up Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 25 - cache: maven - - - name: Build application jar - run: | - mvn clean package -Dmaven.test.skip=true --batch-mode --no-transfer-progress - - # Resolve which SQL file to seed the database with. - # Priority (highest → lowest): - # 1. workflow_dispatch input (sql-file) - # 2. DB_SEED_SQL_FILE repository/environment variable - # 3. .github/config/seed-ci.sql – the default synthetic seed file - # 4. Skip seeding – Liquibase initialises a clean schema only. - - name: Resolve SQL seed file - id: resolve-sql - env: - DISPATCH_FILE: ${{ inputs.sql-file }} - REPO_SQL_FILE: ${{ vars.DB_SEED_SQL_FILE }} - run: | - if [[ -n "$DISPATCH_FILE" && -f "$DISPATCH_FILE" ]]; then - echo "sql-file=$DISPATCH_FILE" >> "$GITHUB_OUTPUT" - elif [[ -n "$REPO_SQL_FILE" && -f "$REPO_SQL_FILE" ]]; then - echo "sql-file=$REPO_SQL_FILE" >> "$GITHUB_OUTPUT" - elif [[ -f ".github/config/seed-ci.sql" ]]; then - echo "sql-file=.github/config/seed-ci.sql" >> "$GITHUB_OUTPUT" - else - echo "sql-file=" >> "$GITHUB_OUTPUT" - echo "No SQL seed file found; Liquibase will initialise a clean schema." - fi - - # Write a stub GCS service-account JSON so the app can start without - # real credentials. All GCS operations will fail at runtime, which is - # acceptable for integration tests that only exercise HTTP/DB paths. - - name: Write stub GCS service-account - run: | - cat > /tmp/ci-gcs-account.json <<'SACEOF' - { - "type": "service_account", - "project_id": "ci-project", - "private_key_id": "ci-key-id", - "private_key": "", - "client_email": "ci@ci-project.iam.gserviceaccount.com", - "client_id": "000000000000000000000", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token" - } - SACEOF - - - name: Start application - run: | - java \ - -classpath "target/classes:$(find target/lib -name '*.jar' | tr '\n' ':')" \ - org.broadinstitute.consent.http.ConsentApplication \ - server .github/config/consent-ci.yaml \ - > /tmp/consent-app.log 2>&1 & - echo "APP_PID=$!" >> "$GITHUB_ENV" - - - name: Wait for application to be healthy - timeout-minutes: 3 - run: | - echo "Waiting for consent app on port 8080..." - for i in $(seq 1 36); do - if curl -sf http://localhost:8080/status > /dev/null 2>&1; then - echo "Application is up after ~$((i * 5)) seconds." - exit 0 - fi - echo " Attempt $i/36 – not ready yet, sleeping 5s..." - sleep 5 - done - echo "Application failed to start within 3 minutes." - echo "=== app log ===" - cat /tmp/consent-app.log - exit 1 - - # Seed runs after the app starts so that Liquibase has already created - # the full schema before any INSERT statements are executed. - - name: Seed database - if: steps.resolve-sql.outputs.sql-file != '' - env: - PGPASSWORD: ci-password - run: | - psql -h localhost -U consent -d consent \ - -f "${{ steps.resolve-sql.outputs.sql-file }}" - - - name: Run integration tests - run: | - mvn test -P integration-tests \ - -DbaseUrl="http://localhost:8080/" \ - --batch-mode - - - name: Stop application - if: always() - run: | - if [[ -n "$APP_PID" ]]; then - kill "$APP_PID" || true - fi - - - name: Upload application log - if: always() - uses: actions/upload-artifact@v4 - with: - name: app-log - path: /tmp/consent-app.log - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: integration-test-reports - path: target/surefire-reports - report-workflow: - if: github.ref == 'refs/heads/develop' - uses: broadinstitute/sherlock/.github/workflows/client-report-workflow.yaml@26b6fc02e7a5bab765d9237d677d184f413fbc85 - with: - relates-to-chart-releases: 'consent-dev' - notify-slack-channels-upon-workflow-failure: ${{ vars.SLACK_NOTIFICATION_CHANNELS }} - notify-slack-channels-upon-workflow-retry: ${{ vars.SLACK_NOTIFICATION_CHANNELS }} - permissions: - id-token: write diff --git a/.github/workflows/smoke-tests.yaml b/.github/workflows/smoke-tests.yaml deleted file mode 100644 index 688b4300a4..0000000000 --- a/.github/workflows/smoke-tests.yaml +++ /dev/null @@ -1,87 +0,0 @@ -name: consent-smoke-tests - -on: - push: - branches: - - develop - pull_request: - branches: - - develop - -jobs: - smoke-tests: - if: false # This job will now be skipped every time - runs-on: ubuntu-latest - permissions: - contents: 'read' - id-token: 'write' - steps: - - name: setup - id: setup - run: - echo "bee-name=${REPO_NAME}-${RUN_ID}-dev" >> $GITHUB_OUTPUT - env: - REPO_NAME: ${{ github.event.repository.name }} - RUN_ID: ${{ github.run_id }} - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - java-version: 25 - cache: 'maven' - - name: Bee Create - uses: broadinstitute/workflow-dispatch@v4 - with: - workflow: bee-create - repo: broadinstitute/terra-github-workflows - ref: refs/heads/main - token: ${{ secrets.BROADBOT_TOKEN}} - inputs: '{ "bee-name": "${{ steps.setup.outputs.bee-name }}", "bee-template-name": "duos", "version-template": "dev" }' - - name: Run Smoke Tests - run: | - mvn clean test -P integration-tests -DbaseUrl="https://consent.${BEE_NAME}.bee.envs-terra.bio/" - env: - BEE_NAME: ${{ steps.setup.outputs.bee-name }} - - name: Store Test Result Artifact - uses: actions/upload-artifact@v7 - if: always() - with: - name: test-reports - path: 'target/surefire-reports' - - name: Bee Destroy - uses: broadinstitute/workflow-dispatch@v4 - if: always() - with: - workflow: bee-destroy - repo: broadinstitute/terra-github-workflows - ref: refs/heads/main - token: ${{ secrets.BROADBOT_TOKEN}} - inputs: '{ "bee-name": "${{ steps.setup.outputs.bee-name }}" }' - - upload-test-reports: - needs: [smoke-tests] - if: false #always() - permissions: - contents: 'read' - id-token: 'write' - uses: broadinstitute/dsp-reusable-workflows/.github/workflows/upload_test_results_to_biquery.yaml@main - with: - service-name: 'duos' - test-uuid: ${{ github.run_id }} - environment: 'dev' - artifact: 'test-reports' - big-query-table: 'broad-dsde-qa.automated_testing.test_results' - subuuid: ${{ github.run_id }} - - report-workflow: - if: false #github.ref == 'refs/heads/develop' - uses: broadinstitute/sherlock/.github/workflows/client-report-workflow.yaml@main - with: - relates-to-chart-releases: 'consent-dev' - notify-slack-channels-upon-workflow-failure: ${{ vars.SLACK_NOTIFICATION_CHANNELS }} - notify-slack-channels-upon-workflow-retry: ${{ vars.SLACK_NOTIFICATION_CHANNELS }} - permissions: - id-token: write diff --git a/pom.xml b/pom.xml index 3d1315e6a5..f99360349f 100644 --- a/pom.xml +++ b/pom.xml @@ -36,52 +36,6 @@ consent - - - all-tests - - true - - - - - org.apache.maven.plugins - maven-surefire-plugin - ${surefire.version} - - - - @{argLine} -Xmx1024m -XX:TieredStopAtLevel=1 - -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar - -Xshare:off - - **/*.java - **/integration/**/*.java - - - - - - - integration-tests - - - - org.apache.maven.plugins - maven-surefire-plugin - ${surefire.version} - - **/integration/**/*.java - - false - - - - - - - - diff --git a/scripts/run-integration-tests.sh b/scripts/run-integration-tests.sh deleted file mode 100755 index 12ee2f7d96..0000000000 --- a/scripts/run-integration-tests.sh +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# run-integration-tests.sh -# -# Runs the integration-test suite locally using the same services, config, and -# seed data that the GitHub Actions workflow uses. -# -# USAGE -# ./scripts/run-integration-tests.sh [OPTIONS] -# -# OPTIONS -# --skip-build Skip `mvn clean package`; use an existing target/ jar. -# --sql-file Path (relative to repo root) to a SQL seed file. -# Defaults to .github/config/seed-ci.sql. -# --base-url Override the baseUrl passed to integration tests. -# Defaults to http://localhost:8080/ -# -h, --help Print this message and exit. -# -# REQUIREMENTS -# docker, mvn, java, psql must all be on PATH. -# ============================================================================= - -set -euo pipefail - -# ── Defaults ───────────────────────────────────────────────────────────────── -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SKIP_BUILD=false -SQL_FILE="${REPO_ROOT}/.github/config/seed-ci.sql" -BASE_URL="http://localhost:8080/" -APP_LOG="/tmp/consent-app.log" -GCS_STUB="/tmp/ci-gcs-account.json" -APP_PID="" - -POSTGRES_CONTAINER="consent-ci-postgres" -ELASTIC_CONTAINER="consent-ci-elastic" -DB_NAME="consent" -DB_USER="consent" -DB_PASS="ci-password" - -# ── Argument parsing ────────────────────────────────────────────────────────── -while [[ $# -gt 0 ]]; do - case "$1" in - --skip-build) SKIP_BUILD=true; shift ;; - --sql-file) SQL_FILE="${REPO_ROOT}/$2"; shift 2 ;; - --base-url) BASE_URL="$2"; shift 2 ;; - -h|--help) - sed -n '/^# USAGE/,/^# REQUIREMENTS/p' "$0" | sed 's/^# \{0,2\}//' - exit 0 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; - esac -done - -# ── Cleanup on exit ─────────────────────────────────────────────────────────── -cleanup() { - echo "" - echo "── Cleanup ──────────────────────────────────────────────────────────" - if [[ -n "$APP_PID" ]] && kill -0 "$APP_PID" 2>/dev/null; then - echo "Stopping application (PID $APP_PID)..." - kill "$APP_PID" || true - fi - echo "Stopping containers..." - docker rm -f "$POSTGRES_CONTAINER" "$ELASTIC_CONTAINER" 2>/dev/null || true - echo "Done." -} -trap cleanup EXIT - -cd "$REPO_ROOT" - -echo "══════════════════════════════════════════════════════════════════════" -echo " Consent Integration Tests – local run" -echo "══════════════════════════════════════════════════════════════════════" - -# ── 1. Build ────────────────────────────────────────────────────────────────── -if [[ "$SKIP_BUILD" == "true" ]]; then - echo "── Step 1/7: Build (skipped) ─────────────────────────────────────────" -else - echo "── Step 1/7: Build ───────────────────────────────────────────────────" - mvn clean package -Dmaven.test.skip=true --batch-mode --no-transfer-progress -fi - -# ── 2. Start PostgreSQL ─────────────────────────────────────────────────────── -echo "── Step 2/7: Start PostgreSQL ────────────────────────────────────────" -docker rm -f "$POSTGRES_CONTAINER" 2>/dev/null || true -docker run -d \ - --name "$POSTGRES_CONTAINER" \ - -e POSTGRES_DB="$DB_NAME" \ - -e POSTGRES_USER="$DB_USER" \ - -e POSTGRES_PASSWORD="$DB_PASS" \ - -p 5432:5432 \ - postgres:16-alpine - -echo -n " Waiting for PostgreSQL..." -for i in $(seq 1 30); do - if docker exec "$POSTGRES_CONTAINER" pg_isready -U "$DB_USER" -d "$DB_NAME" &>/dev/null; then - echo " ready." - break - fi - echo -n "." - sleep 2 - if [[ $i -eq 30 ]]; then - echo " timed out." >&2; exit 1 - fi -done - -# ── 3. Start Elasticsearch ──────────────────────────────────────────────────── -echo "── Step 3/7: Start Elasticsearch ────────────────────────────────────" -docker rm -f "$ELASTIC_CONTAINER" 2>/dev/null || true -docker run -d \ - --name "$ELASTIC_CONTAINER" \ - -e "discovery.type=single-node" \ - -e "xpack.security.enabled=false" \ - -e "cluster.routing.allocation.disk.threshold_enabled=false" \ - -p 9200:9200 \ - elasticsearch:9.3.0 - -echo -n " Waiting for Elasticsearch..." -for i in $(seq 1 30); do - if curl -sf "http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s" &>/dev/null; then - echo " ready." - break - fi - echo -n "." - sleep 5 - if [[ $i -eq 30 ]]; then - echo " timed out." >&2; exit 1 - fi -done - -# ── 4. Write stub GCS credentials ───────────────────────────────────────────── -echo "── Step 4/7: Write stub GCS service-account ──────────────────────────" -cat > "$GCS_STUB" <<'EOF' -{ - "type": "service_account", - "project_id": "ci-project", - "private_key_id": "ci-key-id", - "private_key": "", - "client_email": "ci@ci-project.iam.gserviceaccount.com", - "client_id": "000000000000000000000", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token" -} -EOF - -# ── 5. Start application ────────────────────────────────────────────────────── -echo "── Step 5/7: Start application ───────────────────────────────────────" -echo " Log: $APP_LOG" -java \ - -classpath "target/classes:$(find target/lib -name '*.jar' | tr '\n' ':')" \ - org.broadinstitute.consent.http.ConsentApplication \ - server .github/config/consent-ci.yaml \ - > "$APP_LOG" 2>&1 & -APP_PID=$! - -echo -n " Waiting for application..." -for i in $(seq 1 36); do - if curl -sf "http://localhost:8080/status" &>/dev/null; then - echo " ready (~$((i * 5))s)." - break - fi - echo -n "." - sleep 5 - if [[ $i -eq 36 ]]; then - echo " timed out." >&2 - echo "=== application log ===" >&2 - cat "$APP_LOG" >&2 - exit 1 - fi -done - -# ── 6. Seed database ────────────────────────────────────────────────────────── -# Runs after the app starts so that Liquibase has already created the full -# schema before any INSERT statements are executed. -echo "── Step 6/7: Seed database ───────────────────────────────────────────" -if [[ -f "$SQL_FILE" ]]; then - echo " Using: $SQL_FILE" - PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -f "$SQL_FILE" -else - echo " No seed file found at '$SQL_FILE'; skipping (Liquibase will init schema)." -fi - -# ── 7. Run integration tests ────────────────────────────────────────────────── -echo "── Step 7/7: Run integration tests ──────────────────────────────────" -echo " baseUrl: $BASE_URL" -mvn test -P integration-tests \ - -DbaseUrl="$BASE_URL" \ - --batch-mode - diff --git a/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java b/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java new file mode 100644 index 0000000000..fe37ec067c --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java @@ -0,0 +1,216 @@ +package org.broadinstitute.consent.integration; + +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; + +import com.github.tomakehurst.wiremock.WireMockServer; +import io.dropwizard.core.setup.Environment; +import io.dropwizard.jdbi3.JdbiFactory; +import io.dropwizard.testing.ResourceHelpers; +import io.dropwizard.testing.junit5.DropwizardAppExtension; +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import jakarta.ws.rs.client.Client; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.broadinstitute.consent.http.ConsentApplication; +import org.broadinstitute.consent.http.configurations.ConsentConfiguration; +import org.broadinstitute.consent.http.db.DacDAO; +import org.broadinstitute.consent.http.db.InstitutionDAO; +import org.broadinstitute.consent.http.db.UserDAO; +import org.broadinstitute.consent.http.db.UserRoleDAO; +import org.broadinstitute.consent.http.enumeration.UserRoles; +import org.broadinstitute.consent.http.models.Dac; +import org.broadinstitute.consent.http.models.Institution; +import org.broadinstitute.consent.http.models.User; +import org.broadinstitute.consent.http.util.ConsentLogger; +import org.broadinstitute.consent.http.util.gson.GsonUtil; +import org.jdbi.v3.core.Jdbi; +import org.jdbi.v3.gson2.Gson2Config; +import org.jdbi.v3.gson2.Gson2Plugin; +import org.jdbi.v3.guava.GuavaPlugin; +import org.jdbi.v3.sqlobject.SqlObjectPlugin; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(DropwizardExtensionsSupport.class) +public abstract class ContainerTests implements ConsentLogger { + + protected static final DropwizardAppExtension APPLICATION = + new DropwizardAppExtension<>( + ConsentApplication.class, ResourceHelpers.resourceFilePath("consent-ci.yaml")); + + /** + * WireMock server running on port 9999, which is the fixed base URL used by consent-ci.yaml for + * every external service (Sam, ECM, GCS, etc.). Subclass tests stub specific paths on this server + * before making authenticated API calls. + */ + protected static final WireMockServer WIRE_MOCK = new WireMockServer(options().port(9999)); + + // Note: never close the client returned here — the extension manages its lifetime. + protected static Client getClient() { + return APPLICATION.client(); + } + + /** + * Starts WireMock and seeds the database once before any tests run via typed DAO calls. + * + *

{@link DropwizardExtensionsSupport} implements {@code BeforeAllCallback}, which JUnit 5 + * calls before {@code @BeforeAll} methods, so the application and its database are fully started + * when this method executes. + * + *

Every operation is idempotent: rows are skipped when they already exist. + */ + @BeforeAll + static void seedDatabase() { + if (!WIRE_MOCK.isRunning()) { + WIRE_MOCK.start(); + } + + ConsentConfiguration config = APPLICATION.getConfiguration(); + Environment environment = APPLICATION.getEnvironment(); + + // Build a dedicated JDBI instance for seeding, using the same config/plugins as DAOTestHelper. + Jdbi jdbi = new JdbiFactory().build(environment, config.getDataSourceFactory(), "seed"); + jdbi.installPlugin(new SqlObjectPlugin()); + jdbi.installPlugin(new Gson2Plugin()); + jdbi.installPlugin(new GuavaPlugin()); + jdbi.getConfig().get(Gson2Config.class).setGson(GsonUtil.buildGson()); + + UserDAO userDAO = jdbi.onDemand(UserDAO.class); + UserRoleDAO userRoleDAO = jdbi.onDemand(UserRoleDAO.class); + InstitutionDAO institutionDAO = jdbi.onDemand(InstitutionDAO.class); + DacDAO dacDAO = jdbi.onDemand(DacDAO.class); + + seedUsers(userDAO); + int adminId = userDAO.findUserByEmail("ci-admin@example.com").getUserId(); + seedInstitution(institutionDAO, userDAO, adminId); + seedNonDacRoles(userDAO, userRoleDAO); + int dacId = seedDac(dacDAO, adminId); + seedDacMembers(dacDAO, userDAO, userRoleDAO, dacId, adminId); + } + + @AfterAll + static void stopWireMock() { + if (WIRE_MOCK.isRunning()) { + WIRE_MOCK.stop(); + } + } + + // ------------------------------------------------------------------------- + // Section 1 – Users + // ------------------------------------------------------------------------- + + /** Canonical synthetic users seeded into the CI database before any tests run. */ + public record CiUser(String email, String displayName) {} + + protected static final List CI_USERS = + List.of( + new CiUser("ci-admin@example.com", "CI Admin"), + new CiUser("ci-signing-official@example.com", "CI Signing Official"), + new CiUser("ci-it-director@example.com", "CI IT Director"), + new CiUser("ci-data-submitter@example.com", "CI Data Submitter"), + new CiUser("ci-researcher@example.com", "CI Researcher"), + new CiUser("ci-chair@example.com", "CI DAC Chair"), + new CiUser("ci-member@example.com", "CI DAC Member")); + + private static void seedUsers(UserDAO userDAO) { + Date now = new Date(); + CI_USERS.forEach( + u -> { + if (userDAO.findUserByEmail(u.email()) == null) { + userDAO.insertUser(u.email(), u.displayName(), null, now); + } + }); + } + + // ------------------------------------------------------------------------- + // Section 2 – Institution + // ------------------------------------------------------------------------- + + private static void seedInstitution(InstitutionDAO institutionDAO, UserDAO userDAO, int adminId) { + List existing = institutionDAO.findInstitutionsByName("CI Test Institution"); + int institutionId = + existing.isEmpty() + ? institutionDAO.insertInstitution( + "CI Test Institution", + "CI IT Director", + "ci-it-director@example.com", + null, + null, + null, + null, + null, + null, + adminId, + new Date()) + : existing.getFirst().getId(); + + // Link researcher and signing official to the institution if not already set. + for (String email : List.of("ci-researcher@example.com", "ci-signing-official@example.com")) { + User user = userDAO.findUserByEmail(email); + if (user.getInstitutionId() == null) { + userDAO.updateInstitutionId(user.getUserId(), institutionId); + } + } + } + + // ------------------------------------------------------------------------- + // Section 3 – Non-DAC user roles + // ------------------------------------------------------------------------- + + private static void seedNonDacRoles(UserDAO userDAO, UserRoleDAO userRoleDAO) { + record RoleAssignment(String roleName, String userEmail) {} + List.of( + new RoleAssignment("Admin", "ci-admin@example.com"), + new RoleAssignment("SigningOfficial", "ci-signing-official@example.com"), + new RoleAssignment("ITDirector", "ci-it-director@example.com"), + new RoleAssignment("DataSubmitter", "ci-data-submitter@example.com"), + new RoleAssignment("Researcher", "ci-researcher@example.com")) + .forEach( + ra -> { + int roleId = userRoleDAO.findRoleIdByName(ra.roleName()); + int userId = userDAO.findUserByEmail(ra.userEmail()).getUserId(); + if (userRoleDAO.findRoleByUserIdAndRoleId(userId, roleId) == null) { + userRoleDAO.insertSingleUserRole(roleId, userId); + } + }); + } + + // ------------------------------------------------------------------------- + // Section 4 – DAC (CREATE audit written atomically by createDac) + // ------------------------------------------------------------------------- + + private static int seedDac(DacDAO dacDAO, int adminId) { + return dacDAO.findAll().stream() + .filter(d -> "CI Test DAC".equals(d.getName())) + .findFirst() + .map(Dac::getDacId) + .orElseGet( + () -> dacDAO.createDac("CI Test DAC", "Test DAC for CI integration tests", adminId)); + } + + // ------------------------------------------------------------------------- + // Section 5 – DAC member assignments (ADD audit written atomically by addDacMember) + // ------------------------------------------------------------------------- + + private static void seedDacMembers( + DacDAO dacDAO, UserDAO userDAO, UserRoleDAO userRoleDAO, int dacId, int adminId) { + Set presentMemberIds = + dacDAO.findMembersByDacId(dacId).stream().map(User::getUserId).collect(Collectors.toSet()); + + record DacMember(String roleName, String userEmail) {} + List.of( + new DacMember(UserRoles.CHAIRPERSON.getRoleName(), "ci-chair@example.com"), + new DacMember(UserRoles.MEMBER.getRoleName(), "ci-member@example.com")) + .forEach( + dm -> { + int roleId = userRoleDAO.findRoleIdByName(dm.roleName()); + int userId = userDAO.findUserByEmail(dm.userEmail()).getUserId(); + if (!presentMemberIds.contains(userId)) { + dacDAO.addDacMember(roleId, userId, dacId, adminId); + } + }); + } +} diff --git a/src/test/java/org/broadinstitute/consent/integration/IntegrationTestHelper.java b/src/test/java/org/broadinstitute/consent/integration/IntegrationTestHelper.java deleted file mode 100644 index 83d695070b..0000000000 --- a/src/test/java/org/broadinstitute/consent/integration/IntegrationTestHelper.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.broadinstitute.consent.integration; - -import java.nio.charset.Charset; -import java.util.Optional; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; -import org.apache.commons.io.IOUtils; -import org.apache.hc.client5.http.classic.HttpClient; -import org.apache.hc.client5.http.classic.methods.HttpGet; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.broadinstitute.consent.http.util.HttpClientUtil.SimpleResponse; - -public interface IntegrationTestHelper { - - /** - * Integration tests can pass in an alternative url to test against. By default, we'll test - * against develop. - * - * @return Base URL string: `baseUrl` - */ - default String getBaseUrl() { - String baseUrl = System.getenv("baseUrl"); - return Optional.ofNullable(baseUrl) - .filter(Predicate.not(String::isBlank)) - .orElse("https://consent.dsde-dev.broadinstitute.org/"); - } - - int poolSize = 5; - - long delay = 30; - - default SimpleResponse fetchGetResponse(String path) throws Exception { - HttpClient client = HttpClients.createDefault(); - HttpGet request = new HttpGet(getBaseUrl() + path); - final ScheduledExecutorService executor = Executors.newScheduledThreadPool(poolSize); - executor.schedule(request::cancel, delay, TimeUnit.SECONDS); - return client.execute( - request, - httpResponse -> - new SimpleResponse( - httpResponse.getCode(), - IOUtils.toString(httpResponse.getEntity().getContent(), Charset.defaultCharset()))); - } -} diff --git a/src/test/java/org/broadinstitute/consent/integration/README.md b/src/test/java/org/broadinstitute/consent/integration/README.md index 47729fd053..05efb4a0c8 100644 --- a/src/test/java/org/broadinstitute/consent/integration/README.md +++ b/src/test/java/org/broadinstitute/consent/integration/README.md @@ -1,14 +1,58 @@ # Smoke Testing -Provides a mechanism for running simple smoke tests. The intention here is to keep this -layer as slim as possible to provide a minimum sense of confidence in application stability. +Provides a mechanism for running simple smoke tests against a fully running application stack. +The intention here is to keep this layer as slim as possible to provide a minimum sense of +confidence in application stability. -## Local development/testing process +These tests exercise authenticated HTTP endpoints against a live `DropwizardAppExtension`-managed +application and a real database. They are **not** isolated unit tests. -To run against the default environment (dev), run with no additional arguments: +## How the database is provided + +The integration tests work differently depending on the environment they run in. + +### Local development — no Postgres setup required + +[`DAOTestHelper`](../../db/DAOTestHelper.java) is registered as a JUnit +`TestExecutionListener` via `META-INF/services`. When any test plan starts, it launches a +Testcontainers `PostgreSQLContainer` and calls Dropwizard's `ConfigOverride`, which writes +`dw.database.*` **JVM system properties**. Dropwizard's configuration layer applies `dw.*` +system properties as overrides on every subsequent application start — so +`DropwizardAppExtension` silently connects to the Testcontainers Postgres instead of whatever +URL is in `consent-ci.yaml`. + +Result: no local Postgres is needed; `DAOTestHelper` wires everything transparently. + +### CI — real Postgres at `localhost:5432` + +The CI pipeline provisions a Postgres instance matching the coordinates in `consent-ci.yaml`: + +| Setting | Value | +|----------|--------------------| +| Host | `localhost:5432` | +| Database | `consent` | +| User | `consent` | +| Password | `ci-password` | + +In CI, `-DenableTestContainers=false` **must** be passed. Without it, `DAOTestHelper` would +start Testcontainers and overwrite the real CI database coordinates with `test`/`test` +credentials, causing the tests to run against the wrong database. + +## Running via Maven + +**Local:** ```shell -mvn clean test -P integration-tests +mvn clean test -Dtest="org.broadinstitute.consent.integration.**" ``` -To run against a custom environment, pass a `-DbaseUrl=` with a valid base url. +**CI:** + +```shell +mvn clean test -DenableTestContainers=false -Dtest="org.broadinstitute.consent.integration.**" +``` + +## Running from the IDE + +No extra configuration is needed for local IDE runs. `DAOTestHelper` activates automatically +and provides the database. diff --git a/src/test/java/org/broadinstitute/consent/integration/status/StatusTests.java b/src/test/java/org/broadinstitute/consent/integration/status/StatusTests.java index 7b9f559553..0d4a77c266 100644 --- a/src/test/java/org/broadinstitute/consent/integration/status/StatusTests.java +++ b/src/test/java/org/broadinstitute/consent/integration/status/StatusTests.java @@ -2,40 +2,24 @@ import static org.junit.jupiter.api.Assertions.assertEquals; -import com.google.api.client.http.HttpStatusCodes; -import org.broadinstitute.consent.http.util.HttpClientUtil.SimpleResponse; -import org.broadinstitute.consent.integration.IntegrationTestHelper; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import jakarta.ws.rs.core.Response; +import org.broadinstitute.consent.integration.ContainerTests; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; -/** - * These tests are not parameterized because that displays poorly in the results xml, i.e. compare: - * Parameterized: + *

  • Sam {@code GET /api/users/v2/self/combinedState}: returns a valid {@code + * CombinedState} JSON so {@link + * org.broadinstitute.consent.http.authentication.DuosUserAuthenticator} can build a {@code + * DuosUser} with a non-null {@code UserStatusInfo}. + *
  • ECM {@code GET /api/oauth/v1/ras} (also matches the double-slash form produced by + * the config concatenation): returns 404 so {@link + * org.broadinstitute.consent.http.service.NihService#syncAccount} treats the user as having + * no NIH account and returns the user record cleanly. + * + * + * Both stubs are idempotent and do not need to be reset between tests in this class. + */ + @BeforeAll + static void stubExternalServices() { + // Sam – combinedState: return an enabled user who has accepted the current ToS. + String combinedStateBody = + """ + { + "samUser": { + "email": "ci-user@example.com", + "enabled": true, + "googleSubjectId": "ci-user-google-subject", + "id": "ci-user-google-subject", + "azureB2CId": null, + "createdAt": "2024-01-01T00:00:00.000Z", + "updatedAt": "2024-01-01T00:00:00.000Z" + }, + "termsOfServiceDetails": { + "acceptedOn": "2024-01-01T00:00:00.000Z", + "isCurrentVersion": true, + "latestAcceptedVersion": "v1", + "permitsSystemUsage": true + } + } + """; + WIRE_MOCK.stubFor( + get(urlPathEqualTo("/api/users/v2/self/combinedState")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withStatus(HttpStatusCodes.STATUS_CODE_OK) + .withBody(combinedStateBody))); + + // ECM – RAS provider: 404 → NihService treats this as "no NIH account" and returns the user. + // The config concatenates ecmUrl ("…9999/") + "/api/oauth/v1/ras", which can produce a + // double slash, so the pattern matches both "/api/oauth/v1/ras" and "//api/oauth/v1/ras". + WIRE_MOCK.stubFor( + get(urlPathMatching("/+api/oauth/v1/ras")) + .willReturn(aResponse().withStatus(HttpStatusCodes.STATUS_CODE_NOT_FOUND))); + } + + static Stream ciUsers() { + return CI_USERS.stream(); + } + + /** + * Authenticates as each CI user seeded in {@link ContainerTests} and verifies that {@code GET + * /api/user/me} returns that user's profile. + * + *

    Auth is performed by including the OAUTH2_CLAIM_* headers that the app's {@link + * org.broadinstitute.consent.http.filters.RequestHeaderCacheFilter} reads on every inbound + * request and stores in {@link org.broadinstitute.consent.http.filters.ClaimsCache}, keyed by the + * Bearer token value. The auth filter then resolves the token → claims → user. + * + *

    The Sam {@code combinedState} stub email does not need to match the CI user email; + * authentication resolves the DUOS user via {@code OAUTH2_CLAIM_email}, not the Sam response. + */ + @ParameterizedTest + @MethodSource("ciUsers") + void testGetMeForAllCiUsers(CiUser user) { + String bearer = UUID.randomUUID().toString(); + Response response = + getClient() + .target(String.format("http://localhost:%d/api/user/me", APPLICATION.getLocalPort())) + .request() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + bearer) + .header("OAUTH2_CLAIM_email", user.email()) + .header("OAUTH2_CLAIM_name", user.displayName()) + .header("OAUTH2_CLAIM_access_token", bearer) + .header("OAUTH2_CLAIM_aud", "test-aud") + .get(); + + assertEquals(200, response.getStatus()); + String body = response.readEntity(String.class); + assertTrue( + body.contains(user.email()), + "Response body should contain %s; got: %s".formatted(user.email(), body)); + } +} diff --git a/.github/config/consent-ci.yaml b/src/test/resources/consent-ci.yaml similarity index 97% rename from .github/config/consent-ci.yaml rename to src/test/resources/consent-ci.yaml index 896bd0c8c8..6bec436df8 100644 --- a/.github/config/consent-ci.yaml +++ b/src/test/resources/consent-ci.yaml @@ -26,8 +26,8 @@ logging: database: driverClass: org.postgresql.Driver - user: consent - password: ci-password + user: test + password: test url: jdbc:postgresql://localhost:5432/consent initialSize: 5 minSize: 5 From 0d129ee191d25b2e49802e52cdcbc359e6ba9558 Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 17:21:59 -0400 Subject: [PATCH 10/16] doc: update devnotes --- DEVNOTES.md | 88 +++++++++++++++++++++-------------------------------- 1 file changed, 34 insertions(+), 54 deletions(-) diff --git a/DEVNOTES.md b/DEVNOTES.md index 85a272d19a..7277f310e5 100644 --- a/DEVNOTES.md +++ b/DEVNOTES.md @@ -155,70 +155,50 @@ Run the dependency checker: ## Integration Testing -Integration tests live in `src/test/java/**/integration/` and are run with the -`integration-tests` Maven profile. They make real HTTP calls against a running -instance of the application, so a live server and its backing services must be -available before the tests execute. +Integration tests live in `src/test/java/**/integration/` and are run as part +of the standard `mvn test` lifecycle — no special profile, external server, or +manual Postgres setup is required. -#### How they run in CI +#### How they work -The GitHub Actions workflow at `.github/workflows/integration-tests.yaml` -handles everything automatically on every push/PR to `develop`: - -1. **PostgreSQL 16** and **Elasticsearch 9** are started as service containers. -2. The application jar is built with `mvn clean package`. -3. A SQL seed file is loaded into Postgres to provide baseline test data - (see [SQL seed file priority](#sql-seed-file-priority) below). -4. The application is started with the CI config at - `.github/config/consent-ci.yaml` and the workflow waits for `GET /status` - to return 200. -5. `mvn test -P integration-tests -DbaseUrl=http://localhost:8080/` is run. -6. Test reports are uploaded as a workflow artifact (`integration-test-reports`) - and the application log as `app-log`. - -#### SQL seed file priority - -The workflow resolves which SQL file populates the database using this -precedence (highest to lowest): - -| Priority | Source | -|---|---| -| 1 | `sql-file` input on a manual `workflow_dispatch` trigger | -| 2 | `DB_SEED_SQL_FILE` repository/environment variable (set in **Settings → Variables → Actions**) | -| 3 | `.github/config/seed-ci.sql` — the default synthetic seed checked into the repo | -| 4 | *(nothing)* — Liquibase initialises a clean schema only | - -The seed file at `.github/config/seed-ci.sql` contains one synthetic user per -application role, a test institution, and a test DAC. It is idempotent and -safe to run repeatedly. Add new rows in the clearly marked sections at the -bottom of that file; follow the `ON CONFLICT DO NOTHING` / `WHERE NOT EXISTS` -pattern already used there. +Each test class extends `ContainerTests`, which uses a JUnit 5 +`DropwizardAppExtension` to boot the full application in-process against the +config at `src/test/resources/consent-ci.yaml`. A WireMock server on port 9999 +stands in for all external services (Sam, ECM, GCS, etc.). -#### Running integration tests locally +Database seeding is performed programmatically in `ContainerTests.seedDatabase()` +via typed DAO calls (`@BeforeAll`). The seed data is fully synthetic and +idempotent. To add new baseline rows, extend the relevant `seed*` helper method +inside `ContainerTests`. -A convenience script mirrors the CI workflow exactly: +#### Database -```bash -# Full run: build → start services → seed DB → start app → test → cleanup -./scripts/run-integration-tests.sh +`DAOTestHelper` (a JUnit `TestExecutionListener`) automatically starts a +[Testcontainers](https://www.testcontainers.org/) Postgres instance and +overrides the `dw.database.*` properties before the application boots. No local +Postgres is needed in any environment. -# Skip Maven build when the jar is already up-to-date -./scripts/run-integration-tests.sh --skip-build +#### How they run in CI -# Use a different SQL seed file (e.g. a recent DB dump) -./scripts/run-integration-tests.sh --sql-file config/consent-recent.sql +The GitHub Actions workflow at `.github/workflows/coverage.yaml` runs +`mvn clean test` on every push/PR to `develop`, which exercises unit and +integration tests together via Testcontainers — no additional CI configuration +is needed. -# Run the tests against an already-running environment instead -./scripts/run-integration-tests.sh --base-url https://consent.dsde-dev.broadinstitute.org/ -``` +#### Running integration tests locally -The script requires `docker`, `mvn`, `java`, and `psql` on your `PATH`. -On exit (pass or fail) it automatically stops the application process and -removes the Docker containers it started, so nothing is left running. +**Integration tests only:** -To run the tests manually against an existing environment, pass a custom base -URL directly: +```bash +mvn clean test -Dtest="org.broadinstitute.consent.integration.**" +``` + +**All tests** (unit + integration together, as CI does): ```bash -mvn test -P integration-tests -DbaseUrl=https://consent.dsde-dev.broadinstitute.org/ +mvn clean test ``` + +**From the IDE:** run or debug any test class in the `integration` package +directly — `DAOTestHelper` activates automatically and provides the database. + From 5c74d47ee189e18da7e1445a4c5e51114c74cfc1 Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 19:03:56 -0400 Subject: [PATCH 11/16] fix: copilot feedback --- .../consent/integration/ContainerTests.java | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java b/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java index fe37ec067c..1efef336e0 100644 --- a/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java +++ b/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java @@ -12,6 +12,7 @@ import java.util.Date; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.broadinstitute.consent.http.ConsentApplication; import org.broadinstitute.consent.http.configurations.ConsentConfiguration; @@ -48,19 +49,28 @@ public abstract class ContainerTests implements ConsentLogger { */ protected static final WireMockServer WIRE_MOCK = new WireMockServer(options().port(9999)); + /** + * Guards the one-time database seed so it executes only on the first {@code @BeforeAll} + * invocation across all concrete subclasses in the same JVM, avoiding redundant JDBI setup and + * keeping the seed truly "once per test plan". + */ + private static final AtomicBoolean SEEDED = new AtomicBoolean(false); + // Note: never close the client returned here — the extension manages its lifetime. protected static Client getClient() { return APPLICATION.client(); } /** - * Starts WireMock and seeds the database once before any tests run via typed DAO calls. + * Starts WireMock and seeds the database once per JVM run via typed DAO calls. * *

    {@link DropwizardExtensionsSupport} implements {@code BeforeAllCallback}, which JUnit 5 * calls before {@code @BeforeAll} methods, so the application and its database are fully started - * when this method executes. + * when this method executes. A static {@link AtomicBoolean} guard ensures the expensive JDBI + * setup and seed inserts are performed only on the first invocation, even though {@code + * @BeforeAll} fires once per concrete subclass. * - *

    Every operation is idempotent: rows are skipped when they already exist. + *

    Every insert operation is idempotent: rows are skipped when they already exist. */ @BeforeAll static void seedDatabase() { @@ -68,6 +78,10 @@ static void seedDatabase() { WIRE_MOCK.start(); } + if (!SEEDED.compareAndSet(false, true)) { + return; + } + ConsentConfiguration config = APPLICATION.getConfiguration(); Environment environment = APPLICATION.getEnvironment(); From b0f1b9ac76e64658fb62e6f58ca999629f780db8 Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 19:06:34 -0400 Subject: [PATCH 12/16] fix: copilot feedback --- .../consent/integration/ContainerTests.java | 4 ++-- .../consent/integration/status/StatusTests.java | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java b/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java index 1efef336e0..2563b8f654 100644 --- a/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java +++ b/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java @@ -67,8 +67,8 @@ protected static Client getClient() { *

    {@link DropwizardExtensionsSupport} implements {@code BeforeAllCallback}, which JUnit 5 * calls before {@code @BeforeAll} methods, so the application and its database are fully started * when this method executes. A static {@link AtomicBoolean} guard ensures the expensive JDBI - * setup and seed inserts are performed only on the first invocation, even though {@code - * @BeforeAll} fires once per concrete subclass. + * setup and seed inserts are performed only on the first invocation, even though + * {@code @BeforeAll} fires once per concrete subclass. * *

    Every insert operation is idempotent: rows are skipped when they already exist. */ diff --git a/src/test/java/org/broadinstitute/consent/integration/status/StatusTests.java b/src/test/java/org/broadinstitute/consent/integration/status/StatusTests.java index 0d4a77c266..9bcf6b0995 100644 --- a/src/test/java/org/broadinstitute/consent/integration/status/StatusTests.java +++ b/src/test/java/org/broadinstitute/consent/integration/status/StatusTests.java @@ -17,9 +17,12 @@ class StatusTests extends ContainerTests { "http://localhost:%d/version" }) void testStatusPaths(String path) { - Response response = - getClient().target(String.format(path, APPLICATION.getLocalPort())).request().get(); - logWarn(response.readEntity(String.class)); - assertEquals(200, response.getStatus()); + try (Response response = + getClient().target(String.format(path, APPLICATION.getLocalPort())).request().get()) { + if (response.getStatus() != 200) { + logWarn(response.readEntity(String.class)); + } + assertEquals(200, response.getStatus()); + } } } From 2d408def293eafc76a02cb8fdf66d3e438567bf1 Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 19:08:19 -0400 Subject: [PATCH 13/16] fix: copilot feedback --- .../consent/integration/user/UserTests.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/test/java/org/broadinstitute/consent/integration/user/UserTests.java b/src/test/java/org/broadinstitute/consent/integration/user/UserTests.java index 7a9119ee31..e58ed1209c 100644 --- a/src/test/java/org/broadinstitute/consent/integration/user/UserTests.java +++ b/src/test/java/org/broadinstitute/consent/integration/user/UserTests.java @@ -94,7 +94,7 @@ static Stream ciUsers() { @MethodSource("ciUsers") void testGetMeForAllCiUsers(CiUser user) { String bearer = UUID.randomUUID().toString(); - Response response = + try (Response response = getClient() .target(String.format("http://localhost:%d/api/user/me", APPLICATION.getLocalPort())) .request() @@ -103,12 +103,12 @@ void testGetMeForAllCiUsers(CiUser user) { .header("OAUTH2_CLAIM_name", user.displayName()) .header("OAUTH2_CLAIM_access_token", bearer) .header("OAUTH2_CLAIM_aud", "test-aud") - .get(); - - assertEquals(200, response.getStatus()); - String body = response.readEntity(String.class); - assertTrue( - body.contains(user.email()), - "Response body should contain %s; got: %s".formatted(user.email(), body)); + .get()) { + assertEquals(200, response.getStatus()); + String body = response.readEntity(String.class); + assertTrue( + body.contains(user.email()), + "Response body should contain %s; got: %s".formatted(user.email(), body)); + } } } From f7e48eb911446a0abfd6f581d57936df22b6be49 Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 19:12:45 -0400 Subject: [PATCH 14/16] fix: copilot feedback --- .../consent/integration/ContainerTests.java | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java b/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java index 2563b8f654..b1246a305c 100644 --- a/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java +++ b/src/test/java/org/broadinstitute/consent/integration/ContainerTests.java @@ -5,6 +5,7 @@ import com.github.tomakehurst.wiremock.WireMockServer; import io.dropwizard.core.setup.Environment; import io.dropwizard.jdbi3.JdbiFactory; +import io.dropwizard.testing.ConfigOverride; import io.dropwizard.testing.ResourceHelpers; import io.dropwizard.testing.junit5.DropwizardAppExtension; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; @@ -16,6 +17,7 @@ import java.util.stream.Collectors; import org.broadinstitute.consent.http.ConsentApplication; import org.broadinstitute.consent.http.configurations.ConsentConfiguration; +import org.broadinstitute.consent.http.db.DAOTestHelper; import org.broadinstitute.consent.http.db.DacDAO; import org.broadinstitute.consent.http.db.InstitutionDAO; import org.broadinstitute.consent.http.db.UserDAO; @@ -34,13 +36,36 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.wait.strategy.Wait; @ExtendWith(DropwizardExtensionsSupport.class) public abstract class ContainerTests implements ConsentLogger { + /** + * PostgreSQL container started once per JVM. Static fields are initialized top-to-bottom, so the + * container is running before {@code APPLICATION} is constructed. Testcontainers registers a JVM + * shutdown hook via Ryuk, so no explicit {@code @AfterAll} teardown is required. + */ + @SuppressWarnings("resource") + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(DAOTestHelper.POSTGRES_IMAGE) + .withCommand("postgres -c max_connections=20") + .waitingFor(Wait.forListeningPorts()); + + static { + POSTGRES.start(); + } + protected static final DropwizardAppExtension APPLICATION = new DropwizardAppExtension<>( - ConsentApplication.class, ResourceHelpers.resourceFilePath("consent-ci.yaml")); + ConsentApplication.class, + ResourceHelpers.resourceFilePath("consent-ci.yaml"), + ConfigOverride.config("database.driverClass", POSTGRES.getDriverClassName()), + ConfigOverride.config("database.url", POSTGRES.getJdbcUrl()), + ConfigOverride.config("database.user", POSTGRES.getUsername()), + ConfigOverride.config("database.password", POSTGRES.getPassword()), + ConfigOverride.config("database.validationQuery", POSTGRES.getTestQueryString())); /** * WireMock server running on port 9999, which is the fixed base URL used by consent-ci.yaml for From a75a0fd63d3a09ea371010e8b1fc861d68785eab Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 19:15:25 -0400 Subject: [PATCH 15/16] fix: copilot feedback --- .../consent/integration/README.md | 56 +++++++------------ 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/src/test/java/org/broadinstitute/consent/integration/README.md b/src/test/java/org/broadinstitute/consent/integration/README.md index 05efb4a0c8..530475ba0f 100644 --- a/src/test/java/org/broadinstitute/consent/integration/README.md +++ b/src/test/java/org/broadinstitute/consent/integration/README.md @@ -1,4 +1,4 @@ -# Smoke Testing +# Integration Testing Provides a mechanism for running simple smoke tests against a fully running application stack. The intention here is to keep this layer as slim as possible to provide a minimum sense of @@ -9,50 +9,34 @@ application and a real database. They are **not** isolated unit tests. ## How the database is provided -The integration tests work differently depending on the environment they run in. +[`ContainerTests`](ContainerTests.java) starts a Testcontainers `PostgreSQLContainer` in a +static initializer — before `DropwizardAppExtension` is constructed — and passes the +container's coordinates directly as `ConfigOverride` entries: -### Local development — no Postgres setup required - -[`DAOTestHelper`](../../db/DAOTestHelper.java) is registered as a JUnit -`TestExecutionListener` via `META-INF/services`. When any test plan starts, it launches a -Testcontainers `PostgreSQLContainer` and calls Dropwizard's `ConfigOverride`, which writes -`dw.database.*` **JVM system properties**. Dropwizard's configuration layer applies `dw.*` -system properties as overrides on every subsequent application start — so -`DropwizardAppExtension` silently connects to the Testcontainers Postgres instead of whatever -URL is in `consent-ci.yaml`. - -Result: no local Postgres is needed; `DAOTestHelper` wires everything transparently. - -### CI — real Postgres at `localhost:5432` - -The CI pipeline provisions a Postgres instance matching the coordinates in `consent-ci.yaml`: +```java +ConfigOverride.config("database.url", POSTGRES.getJdbcUrl()), +ConfigOverride.config("database.user", POSTGRES.getUsername()), +ConfigOverride.config("database.password", POSTGRES.getPassword()), +ConfigOverride.config("database.driverClass", POSTGRES.getDriverClassName()), +ConfigOverride.config("database.validationQuery", POSTGRES.getTestQueryString()) +``` -| Setting | Value | -|----------|--------------------| -| Host | `localhost:5432` | -| Database | `consent` | -| User | `consent` | -| Password | `ci-password` | +This means: -In CI, `-DenableTestContainers=false` **must** be passed. Without it, `DAOTestHelper` would -start Testcontainers and overwrite the real CI database coordinates with `test`/`test` -credentials, causing the tests to run against the wrong database. +- **No local Postgres installation is needed** — the container is started automatically. +- **No CI database provisioning is needed** — the same container is used in CI. +- The hardcoded coordinates in `consent-ci.yaml` are never reached at runtime; they serve + only as documentation of the expected schema. +- Testcontainers registers a JVM shutdown hook (via Ryuk) that stops the container when the + test JVM exits — no manual teardown is required. ## Running via Maven -**Local:** - ```shell mvn clean test -Dtest="org.broadinstitute.consent.integration.**" ``` -**CI:** - -```shell -mvn clean test -DenableTestContainers=false -Dtest="org.broadinstitute.consent.integration.**" -``` - ## Running from the IDE -No extra configuration is needed for local IDE runs. `DAOTestHelper` activates automatically -and provides the database. +No extra configuration is needed. The container starts automatically when the test class is +loaded. From cebf7cb3c9033b8acf3d090844db1834b1a3a506 Mon Sep 17 00:00:00 2001 From: rushtong Date: Fri, 1 May 2026 19:17:27 -0400 Subject: [PATCH 16/16] fix: copilot feedback --- DEVNOTES.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/DEVNOTES.md b/DEVNOTES.md index 7277f310e5..a3013a72a7 100644 --- a/DEVNOTES.md +++ b/DEVNOTES.md @@ -173,10 +173,11 @@ inside `ContainerTests`. #### Database -`DAOTestHelper` (a JUnit `TestExecutionListener`) automatically starts a -[Testcontainers](https://www.testcontainers.org/) Postgres instance and -overrides the `dw.database.*` properties before the application boots. No local -Postgres is needed in any environment. +`ContainerTests` starts its own [Testcontainers](https://www.testcontainers.org/) +`PostgreSQLContainer` in a static initializer and passes the container's +coordinates directly to `DropwizardAppExtension` via `ConfigOverride`. The +hardcoded coordinates in `consent-ci.yaml` are never reached at runtime. No +local Postgres is needed in any environment. #### How they run in CI