diff --git a/.cfignore b/.cfignore index 475b8fff1..848d124e3 100644 --- a/.cfignore +++ b/.cfignore @@ -41,3 +41,4 @@ # Ignore Rust build artifacts target/ ext/widget_renderer/target/ +!ext/widget_renderer/libwidget_renderer.so diff --git a/.circleci/config.yml b/.circleci/config.yml index 4401f6f7e..d73602fe4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,10 +4,15 @@ # version: 2.1 +# Cancel redundant builds when new commits are pushed +# This prevents multiple pipelines from racing to deploy +# Note: This must also be enabled in CircleCI project settings +# Settings > Advanced > Auto-cancel redundant builds + jobs: build: docker: - - image: cimg/ruby:3.4.7-browsers # Updated to match Gemfile Ruby version + - image: cimg/ruby:3.2.8-browsers # Matches deployed Ruby version in CF environment: RAILS_ENV: test PGHOST: 127.0.0.1 @@ -41,6 +46,51 @@ jobs: - checkout + - run: + name: Install Rust toolchain + command: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + echo 'source $HOME/.cargo/env' >> $BASH_ENV + source $HOME/.cargo/env + rustc --version + cargo --version + + - restore_cache: + keys: + - v4-cargo-{{ checksum "ext/widget_renderer/Cargo.lock" }}-{{ checksum "ext/widget_renderer/src/template_renderer.rs" }} + - v4-cargo-{{ checksum "ext/widget_renderer/Cargo.lock" }}- + + - run: + name: Build widget renderer (Rust) + command: | + source $HOME/.cargo/env + cargo build --release --manifest-path ext/widget_renderer/Cargo.toml + + - run: + name: Verify Rust native library linkage + command: | + set -euo pipefail + LIB=ext/widget_renderer/target/release/libwidget_renderer.so + if [ -f "$LIB" ]; then + echo "Found built rust library; verifying linkage..." + if ldd "$LIB" 2>&1 | grep -q "not found"; then + echo "ERROR: Rust library has unresolved dependencies (ldd shows 'not found')." + ldd "$LIB" || true + exit 1 + else + echo "Rust library linkage looks good" + fi + else + echo "No Rust library built - skipping linkage verification" + fi + + - save_cache: + paths: + - ext/widget_renderer/target + - ~/.cargo/registry + - ~/.cargo/git + key: v4-cargo-{{ checksum "ext/widget_renderer/Cargo.lock" }}-{{ checksum "ext/widget_renderer/src/template_renderer.rs" }} + # Download and cache dependencies - restore_cache: keys: @@ -71,17 +121,20 @@ jobs: mkdir /tmp/test-results TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)" - bundle exec rspec --format progress \ - --format RspecJunitFormatter \ - --out /tmp/test-results/rspec.xml \ - --format progress \ - $TEST_FILES + bundle exec rspec --format progress \ + --format RspecJunitFormatter \ + --out /tmp/test-results/rspec.xml \ + --format progress \ + $TEST_FILES # Install Cloud Foundry cli (cf) before deploy step. cf is used to push to Cloud.gov - run: name: Install-cf-cli command: | - curl -v -L -o cf-cli_amd64.deb 'https://packages.cloudfoundry.org/stable?release=debian64&source=github' - sudo dpkg -i cf-cli_amd64.deb + # Download and convert the GPG key to binary format for modern apt + wget -q -O - https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key | sudo gpg --dearmor -o /usr/share/keyrings/cloudfoundry-cli.gpg + echo "deb [signed-by=/usr/share/keyrings/cloudfoundry-cli.gpg] https://packages.cloudfoundry.org/debian stable main" | sudo tee /etc/apt/sources.list.d/cloudfoundry-cli.list + sudo apt-get update + sudo apt-get install -y cf8-cli cf -v # collect reports - store_test_results: @@ -91,11 +144,36 @@ jobs: - run: name: Deploy Sidekiq worker servers - command: ./.circleci/deploy-sidekiq.sh + command: | + # Only deploy from a single parallel node to avoid concurrent CF pushes. + if [ "${CIRCLE_NODE_INDEX:-0}" != "0" ]; then + echo "Skipping Sidekiq deploy on parallel node ${CIRCLE_NODE_INDEX}" + exit 0 + fi + # Keep prebuilt Rust library - extconf.rb builds it during bundle install with correct paths + # The library is built with rutie which properly links against the CF Ruby installation + # echo "Removing prebuilt Rust library (will be rebuilt on CF)..." + # rm -rf ext/widget_renderer/target/release/libwidget_renderer.so 2>/dev/null || true + # rm -f ext/widget_renderer/libwidget_renderer.so 2>/dev/null || true + ./.circleci/deploy-sidekiq.sh + no_output_timeout: 30m - run: name: Deploy web server(s) - command: ./.circleci/deploy.sh + command: | + # Only deploy from a single parallel node to avoid concurrent CF pushes. + if [ "${CIRCLE_NODE_INDEX:-0}" != "0" ]; then + echo "Skipping web deploy on parallel node ${CIRCLE_NODE_INDEX}" + exit 0 + fi + # Wait for Sidekiq deployment to complete before starting web deploy + sleep 120 + # Keep prebuilt Rust library - extconf.rb builds it during bundle install with correct paths + # echo "Removing prebuilt Rust library (will be rebuilt on CF)..." + # rm -rf ext/widget_renderer/target/release/libwidget_renderer.so 2>/dev/null || true + # rm -f ext/widget_renderer/libwidget_renderer.so 2>/dev/null || true + ./.circleci/deploy.sh + no_output_timeout: 30m cron_tasks: docker: @@ -105,8 +183,11 @@ jobs: - run: name: Install-cf-cli command: | - curl -v -L -o cf-cli_amd64.deb 'https://packages.cloudfoundry.org/stable?release=debian64&source=github' - sudo dpkg -i cf-cli_amd64.deb + # Download and convert the GPG key to binary format for modern apt + wget -q -O - https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key | sudo gpg --dearmor -o /usr/share/keyrings/cloudfoundry-cli.gpg + echo "deb [signed-by=/usr/share/keyrings/cloudfoundry-cli.gpg] https://packages.cloudfoundry.org/debian stable main" | sudo tee /etc/apt/sources.list.d/cloudfoundry-cli.list + sudo apt-get update + sudo apt-get install -y cf8-cli cf -v - run: name: Run CRON tasks diff --git a/.circleci/deploy-sidekiq.sh b/.circleci/deploy-sidekiq.sh index e4bdea0ce..df34be894 100755 --- a/.circleci/deploy-sidekiq.sh +++ b/.circleci/deploy-sidekiq.sh @@ -4,13 +4,176 @@ # a non-zero exit code set -e +# Acquire a deployment lock using CF environment variable +# This prevents multiple pipelines from deploying simultaneously +acquire_deploy_lock() { + local app_name="$1" + local lock_name="DEPLOY_LOCK" + local lock_value="${CIRCLE_BUILD_NUM:-$$}_$(date +%s)" + local max_wait=600 # 10 minutes max + local wait_interval=30 + local waited=0 + + echo "Attempting to acquire deploy lock for $app_name..." + + while [ $waited -lt $max_wait ]; do + # Check if there's an existing lock + local current_lock=$(cf env "$app_name" 2>/dev/null | grep "$lock_name:" | awk '{print $2}' || echo "") + + if [ -z "$current_lock" ] || [ "$current_lock" == "null" ]; then + # No lock exists, try to acquire it + echo "Setting deploy lock: $lock_value" + cf set-env "$app_name" "$lock_name" "$lock_value" > /dev/null 2>&1 || true + sleep 5 # Small delay to handle race conditions + + # Verify we got the lock + current_lock=$(cf env "$app_name" 2>/dev/null | grep "$lock_name:" | awk '{print $2}' || echo "") + if [ "$current_lock" == "$lock_value" ]; then + echo "Deploy lock acquired: $lock_value" + return 0 + fi + fi + + # Check if lock is stale (older than 15 minutes) + local lock_time=$(echo "$current_lock" | cut -d'_' -f2) + local now=$(date +%s) + if [ -n "$lock_time" ] && [ $((now - lock_time)) -gt 900 ]; then + echo "Stale lock detected (age: $((now - lock_time))s), clearing..." + cf unset-env "$app_name" "$lock_name" > /dev/null 2>&1 || true + continue + fi + + echo "Deploy lock held by another process ($current_lock), waiting ${wait_interval}s... (waited ${waited}s)" + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Warning: Could not acquire lock after ${max_wait}s, proceeding anyway..." + return 0 +} + +# Release the deployment lock +release_deploy_lock() { + local app_name="$1" + local lock_name="DEPLOY_LOCK" + echo "Releasing deploy lock for $app_name..." + cf unset-env "$app_name" "$lock_name" > /dev/null 2>&1 || true +} + +# Wait for any in-progress deployments to complete before starting +wait_for_deployment() { + local app_name="$1" + local max_wait=600 # 10 minutes max + local wait_interval=15 + local waited=0 + + echo "Checking for in-progress deployments of $app_name..." + + while [ $waited -lt $max_wait ]; do + # Get deployment status - look for ACTIVE deployments + local status=$(cf curl "/v3/deployments?app_guids=$(cf app "$app_name" --guid)&status_values=ACTIVE" 2>/dev/null | grep -o '"state":"[^"]*"' | head -1 || echo "") + + if [ -z "$status" ] || [[ "$status" == *'"state":"FINALIZED"'* ]] || [[ "$status" == *'"state":"DEPLOYED"'* ]]; then + echo "No active deployment in progress, proceeding..." + return 0 + fi + + echo "Deployment in progress ($status), waiting ${wait_interval}s... (waited ${waited}s of ${max_wait}s)" + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Warning: Timed out waiting for previous deployment, proceeding anyway..." + return 0 +} + +# Retry function to handle staging and deployment conflicts +cf_push_with_retry() { + local app_name="$1" + local max_retries=5 + local retry_delay=90 + + # Acquire lock first + acquire_deploy_lock "$app_name" + + # Ensure lock is released on exit + trap "release_deploy_lock '$app_name'" EXIT + + # Wait for any in-progress deployment + wait_for_deployment "$app_name" + + # Update app to use 180s invocation timeout and process health check before rolling deploy + echo "Updating health check configuration for $app_name..." + cf set-health-check "$app_name" process --invocation-timeout 180 || true + sleep 2 + + # Get current instance count and scale down to 1 to avoid memory quota issues during rolling deploy + echo "Checking current instance count for $app_name..." + local current_instances=$(cf app "$app_name" | grep "^instances:" | awk '{print $2}' | cut -d'/' -f2 || echo "1") + echo "Current instances: $current_instances" + + if [ "$current_instances" -gt 1 ]; then + echo "Scaling down to 1 instance to free memory for rolling deploy..." + cf scale "$app_name" -i 1 || true + sleep 5 + fi + + for i in $(seq 1 $max_retries); do + echo "Attempt $i of $max_retries to push $app_name..." + + # Stop the app first to free memory for staging + echo "Stopping $app_name to free memory for staging..." + cf stop "$app_name" || true + sleep 5 + + # Push without rolling strategy (direct replacement since we stopped it) + # Let CF auto-detect buildpacks to avoid re-running supply phase (Rust already built in CircleCI) + if cf push "$app_name" \ + -t 180 \ + --health-check-type process; then + echo "Successfully pushed $app_name" + + # Scale back up to original instance count + if [ "$current_instances" -gt 1 ]; then + echo "Scaling up to $current_instances instances..." + cf scale "$app_name" -i "$current_instances" || true + fi + + release_deploy_lock "$app_name" + trap - EXIT # Clear the trap + return 0 + else + local exit_code=$? + if [ $i -lt $max_retries ]; then + echo "Push failed (exit code: $exit_code), waiting ${retry_delay}s before retry..." + sleep $retry_delay + # Re-check for in-progress deployments before retrying + wait_for_deployment "$app_name" + fi + fi + done + + # If we failed, try to scale back up anyway + if [ "$current_instances" -gt 1 ]; then + echo "Deploy failed, attempting to scale back up to $current_instances instances..." + cf scale "$app_name" -i "$current_instances" || true + fi + + release_deploy_lock "$app_name" + trap - EXIT # Clear the trap + echo "Failed to push $app_name after $max_retries attempts" + return 1 +} + if [ "${CIRCLE_BRANCH}" == "production" ] then echo "Logging into cloud.gov" # Log into CF and push cf login -a $CF_API_ENDPOINT -u $CF_PRODUCTION_SPACE_DEPLOYER_USERNAME -p $CF_PRODUCTION_SPACE_DEPLOYER_PASSWORD -o $CF_ORG -s prod echo "PUSHING to PRODUCTION..." - cf push touchpoints-production-sidekiq-worker --strategy rolling + echo "Syncing Login.gov environment variables..." + ./.circleci/sync-login-gov-env.sh touchpoints-production-sidekiq-worker + cf_push_with_retry touchpoints-production-sidekiq-worker echo "Push to Production Complete." else echo "Not on the production branch." @@ -22,7 +185,7 @@ then # Log into CF and push cf login -a $CF_API_ENDPOINT -u $CF_USERNAME -p $CF_PASSWORD -o $CF_ORG -s $CF_SPACE echo "Pushing to Demo..." - cf push touchpoints-demo-sidekiq-worker --strategy rolling + cf_push_with_retry touchpoints-demo-sidekiq-worker echo "Push to Demo Complete." else echo "Not on the main branch." @@ -34,7 +197,7 @@ then # Log into CF and push cf login -a $CF_API_ENDPOINT -u $CF_USERNAME -p $CF_PASSWORD -o $CF_ORG -s $CF_SPACE echo "Pushing to Staging..." - cf push touchpoints-staging-sidekiq-worker --strategy rolling + cf_push_with_retry touchpoints-staging-sidekiq-worker echo "Push to Staging Complete." else echo "Not on the develop branch." diff --git a/.circleci/deploy.sh b/.circleci/deploy.sh index 5ec21c0a6..a70ce758c 100755 --- a/.circleci/deploy.sh +++ b/.circleci/deploy.sh @@ -4,13 +4,292 @@ # a non-zero exit code set -e +# Acquire a deployment lock using CF environment variable +# This prevents multiple pipelines from deploying simultaneously +acquire_deploy_lock() { + local app_name="$1" + local lock_name="DEPLOY_LOCK" + local lock_value="${CIRCLE_BUILD_NUM:-$$}_$(date +%s)" + local max_wait=600 # 10 minutes max + local wait_interval=30 + local waited=0 + + echo "Attempting to acquire deploy lock for $app_name..." + + while [ $waited -lt $max_wait ]; do + # Check if there's an existing lock + local current_lock=$(cf env "$app_name" 2>/dev/null | grep "$lock_name:" | awk '{print $2}' || echo "") + + if [ -z "$current_lock" ] || [ "$current_lock" == "null" ]; then + # No lock exists, try to acquire it + echo "Setting deploy lock: $lock_value" + cf set-env "$app_name" "$lock_name" "$lock_value" > /dev/null 2>&1 || true + sleep 5 # Small delay to handle race conditions + + # Verify we got the lock + current_lock=$(cf env "$app_name" 2>/dev/null | grep "$lock_name:" | awk '{print $2}' || echo "") + if [ "$current_lock" == "$lock_value" ]; then + echo "Deploy lock acquired: $lock_value" + return 0 + fi + fi + + # Check if lock is stale (older than 15 minutes) + local lock_time=$(echo "$current_lock" | cut -d'_' -f2) + local now=$(date +%s) + if [ -n "$lock_time" ] && [ $((now - lock_time)) -gt 900 ]; then + echo "Stale lock detected (age: $((now - lock_time))s), clearing..." + cf unset-env "$app_name" "$lock_name" > /dev/null 2>&1 || true + continue + fi + + echo "Deploy lock held by another process ($current_lock), waiting ${wait_interval}s... (waited ${waited}s)" + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Warning: Could not acquire lock after ${max_wait}s, proceeding anyway..." + return 0 +} + +# Release the deployment lock +release_deploy_lock() { + local app_name="$1" + local lock_name="DEPLOY_LOCK" + echo "Releasing deploy lock for $app_name..." + cf unset-env "$app_name" "$lock_name" > /dev/null 2>&1 || true +} + +# Wait for any in-progress deployments to complete before starting +wait_for_deployment() { + local app_name="$1" + local max_wait=800 # 13 minutes and 20 seconds max + local wait_interval=15 + local waited=0 + + echo "Checking for in-progress deployments of $app_name..." + + while [ $waited -lt $max_wait ]; do + # Get deployment status - look for ACTIVE deployments + local status=$(cf curl "/v3/deployments?app_guids=$(cf app "$app_name" --guid)&status_values=ACTIVE" 2>/dev/null | grep -o '"state":"[^"]*"' | head -1 || echo "") + + if [ -z "$status" ] || [[ "$status" == *'"state":"FINALIZED"'* ]] || [[ "$status" == *'"state":"DEPLOYED"'* ]]; then + echo "No active deployment in progress, proceeding..." + return 0 + fi + + echo "Deployment in progress ($status), waiting ${wait_interval}s... (waited ${waited}s of ${max_wait}s)" + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Warning: Timed out waiting for previous deployment, proceeding anyway..." + return 0 +} + +# Wait for the current deployment to fully complete (all instances replaced) +wait_for_deployment_complete() { + local app_name="$1" + local max_wait=900 # 15 minutes max for full deployment + local wait_interval=15 + local waited=0 + + echo "Waiting for deployment of $app_name to complete..." + + local app_guid=$(cf app "$app_name" --guid) + + while [ $waited -lt $max_wait ]; do + # Get the most recent deployment status + local deployment_info=$(cf curl "/v3/deployments?app_guids=${app_guid}&order_by=-created_at&per_page=1" 2>/dev/null) + local status=$(echo "$deployment_info" | grep -o '"value":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "") + local reason=$(echo "$deployment_info" | grep -o '"reason":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "") + + if [ "$status" == "FINALIZED" ]; then + if [ "$reason" == "DEPLOYED" ]; then + echo "✓ Deployment completed successfully" + return 0 + elif [ "$reason" == "CANCELED" ]; then + echo "✗ Deployment was canceled" + return 1 + else + echo "✗ Deployment finalized with reason: $reason" + return 1 + fi + fi + + if [ "$status" == "ACTIVE" ]; then + echo "Deployment in progress (status: $status), waiting ${wait_interval}s... (waited ${waited}s of ${max_wait}s)" + else + echo "Deployment status: $status, reason: $reason" + fi + + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Warning: Timed out waiting for deployment to complete after ${max_wait}s" + return 1 +} + +# Run migrations as a CF task and wait for completion +run_migrations() { + local app_name="$1" + local max_wait=1800 # 30 minutes max for migrations + local wait_interval=10 + local waited=0 + + echo "Running database migrations for $app_name..." + + # Start migration task + local task_output=$(cf run-task "$app_name" --command "bundle exec rails db:migrate" --name "pre-deploy-migrations" 2>&1) + echo "$task_output" + + # Extract task ID from output + local task_id=$(echo "$task_output" | grep -oE 'task id:[[:space:]]+[0-9]+' | grep -oE '[0-9]+' || echo "") + + if [ -z "$task_id" ]; then + echo "Warning: Could not determine task ID, checking tasks list..." + sleep 5 + task_id=$(cf tasks "$app_name" | grep "pre-deploy-migrations" | grep "RUNNING" | head -1 | awk '{print $1}') + fi + + if [ -z "$task_id" ]; then + echo "Error: Failed to start migration task" + return 1 + fi + + echo "Migration task started with ID: $task_id" + echo "Waiting for migrations to complete..." + + # Wait for task to complete + while [ $waited -lt $max_wait ]; do + local task_state=$(cf tasks "$app_name" | grep "^$task_id " | awk '{print $3}') + + if [ "$task_state" == "SUCCEEDED" ]; then + echo "✓ Migrations completed successfully" + return 0 + elif [ "$task_state" == "FAILED" ]; then + echo "✗ Migration task failed. Checking logs..." + cf logs "$app_name" --recent | grep "pre-deploy-migrations" | tail -50 + return 1 + fi + + if [ $((waited % 30)) -eq 0 ]; then + echo "Migration task still running (state: $task_state, waited ${waited}s)..." + fi + + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Error: Migration task did not complete within ${max_wait}s" + cf logs "$app_name" --recent | grep "pre-deploy-migrations" | tail -50 + return 1 +} + +# Retry function to handle staging and deployment conflicts +cf_push_with_retry() { + local app_name="$1" + local manifest_path="${2:-}" + local run_migrations="${3:-false}" + local max_retries=5 + local retry_delay=90 + + # Run migrations first if requested + if [ "$run_migrations" == "true" ]; then + if ! run_migrations "$app_name"; then + echo "Error: Migrations failed, aborting deployment" + return 1 + fi + fi + + # Ensure CircleCI-built Rust library is present + if [ -f "ext/widget_renderer/target/release/libwidget_renderer.so" ]; then + echo "CircleCI-built Rust library found, will be included in deployment" + file ext/widget_renderer/target/release/libwidget_renderer.so + readelf -n ext/widget_renderer/target/release/libwidget_renderer.so | grep "Build ID" || true + else + echo "WARNING: No CircleCI-built Rust library found at ext/widget_renderer/target/release/libwidget_renderer.so" + fi + + # Acquire lock first + acquire_deploy_lock "$app_name" + + # Ensure lock is released on exit + trap "release_deploy_lock '$app_name'" EXIT + + # Wait for any in-progress deployment + wait_for_deployment "$app_name" + + for i in $(seq 1 $max_retries); do + echo "Attempt $i of $max_retries to push $app_name..." + local exit_code=0 + + set +e + if [ -n "$manifest_path" ]; then + echo "Using manifest: $manifest_path" + cf push "$app_name" -f "$manifest_path" --strategy rolling -t 180 --no-wait + else + cf push "$app_name" --strategy rolling -t 180 --no-wait + fi + exit_code=$? + set -e + + if [ $exit_code -eq 0 ]; then + echo "Push initiated successfully, waiting for full deployment to complete..." + if wait_for_deployment_complete "$app_name"; then + echo "Successfully deployed $app_name" + release_deploy_lock "$app_name" + trap - EXIT # Clear the trap + return 0 + else + echo "Deployment did not complete successfully" + # Continue to retry logic below + fi + fi + + if [ $i -lt $max_retries ]; then + echo "Push failed or deployment incomplete (exit code: $exit_code), checking for active deployments..." + + # Check if there's an active deployment that we should wait for instead of retrying + local app_guid=$(cf app "$app_name" --guid 2>/dev/null || echo "") + if [ -n "$app_guid" ]; then + local active_deployment=$(cf curl "/v3/deployments?app_guids=${app_guid}&status_values=ACTIVE" 2>/dev/null | grep -c '"ACTIVE"' || echo "0") + + if [ "$active_deployment" -gt 0 ]; then + echo "Active deployment detected, waiting for it to complete instead of retrying..." + if wait_for_deployment_complete "$app_name"; then + echo "Existing deployment completed successfully" + release_deploy_lock "$app_name" + trap - EXIT + return 0 + fi + echo "Existing deployment did not complete successfully, will retry..." + fi + fi + + echo "Waiting ${retry_delay}s before retry..." + sleep $retry_delay + # Re-check for in-progress deployments before retrying + wait_for_deployment "$app_name" + fi + done + + release_deploy_lock "$app_name" + trap - EXIT # Clear the trap + echo "Failed to push $app_name after $max_retries attempts" + return 1 +} + if [ "${CIRCLE_BRANCH}" == "production" ] then echo "Logging into cloud.gov" # Log into CF and push cf login -a $CF_API_ENDPOINT -u $CF_PRODUCTION_SPACE_DEPLOYER_USERNAME -p $CF_PRODUCTION_SPACE_DEPLOYER_PASSWORD -o $CF_ORG -s prod echo "PUSHING web servers to Production..." - cf push touchpoints --strategy rolling + echo "Syncing Login.gov environment variables..." + ./.circleci/sync-login-gov-env.sh touchpoints + cf_push_with_retry touchpoints touchpoints.yml false echo "Push to Production Complete." else echo "Not on the production branch." @@ -22,7 +301,7 @@ then # Log into CF and push cf login -a $CF_API_ENDPOINT -u $CF_USERNAME -p $CF_PASSWORD -o $CF_ORG -s $CF_SPACE echo "Pushing web servers to Demo..." - cf push touchpoints-demo --strategy rolling + cf_push_with_retry touchpoints-demo "" true echo "Push to Demo Complete." else echo "Not on the main branch." @@ -34,7 +313,7 @@ then # Log into CF and push cf login -a $CF_API_ENDPOINT -u $CF_USERNAME -p $CF_PASSWORD -o $CF_ORG -s $CF_SPACE echo "Pushing web servers to Staging..." - cf push touchpoints-staging --strategy rolling + cf_push_with_retry touchpoints-staging "" true echo "Push to Staging Complete." else echo "Not on the develop branch." diff --git a/.circleci/sync-login-gov-env.sh b/.circleci/sync-login-gov-env.sh new file mode 100755 index 000000000..e869bc618 --- /dev/null +++ b/.circleci/sync-login-gov-env.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -euo pipefail + +require_env() { + local var_name="$1" + if [ -z "${!var_name:-}" ]; then + echo "Missing required env var: ${var_name}" >&2 + exit 1 + fi +} + +escape_private_key() { + ruby -e 'print STDIN.read.gsub("\r\n", "\n").gsub("\n", "\\n")' +} + +sync_login_gov_env() { + local app_name="$1" + + require_env LOGIN_GOV_CLIENT_ID + require_env LOGIN_GOV_IDP_BASE_URL + require_env LOGIN_GOV_REDIRECT_URI + require_env LOGIN_GOV_PRIVATE_KEY + + local private_key_escaped + private_key_escaped="$(printf "%s" "${LOGIN_GOV_PRIVATE_KEY}" | escape_private_key)" + + cf set-env "$app_name" LOGIN_GOV_CLIENT_ID "$LOGIN_GOV_CLIENT_ID" >/dev/null + cf set-env "$app_name" LOGIN_GOV_IDP_BASE_URL "$LOGIN_GOV_IDP_BASE_URL" >/dev/null + cf set-env "$app_name" LOGIN_GOV_REDIRECT_URI "$LOGIN_GOV_REDIRECT_URI" >/dev/null + cf set-env "$app_name" LOGIN_GOV_PRIVATE_KEY "$private_key_escaped" >/dev/null + + echo "Synced Login.gov env to ${app_name}" +} + +if [ "${1:-}" == "" ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +sync_login_gov_env "$1" + diff --git a/.github/workflows/build-widget.yml b/.github/workflows/build-widget.yml index 6b49462ef..b8d5c9ec1 100644 --- a/.github/workflows/build-widget.yml +++ b/.github/workflows/build-widget.yml @@ -23,18 +23,25 @@ jobs: uses: actions-rs/toolchain@v1 with: toolchain: stable - target: x86_64-unknown-linux-gnu override: true - name: Build widget (Linux .so) working-directory: ext/widget_renderer - run: cargo build --release --target x86_64-unknown-linux-gnu + run: cargo build --release - name: Prepare artifact for CF run: | - mkdir -p ext/widget_renderer/target/release - cp ext/widget_renderer/target/x86_64-unknown-linux-gnu/release/libwidget_renderer.so ext/widget_renderer/ - cp ext/widget_renderer/target/x86_64-unknown-linux-gnu/release/libwidget_renderer.so ext/widget_renderer/target/release/ + set -euo pipefail + mkdir -p ext/widget_renderer/target/release target/release + artifact=$(find target ext/widget_renderer/target -maxdepth 4 -name 'libwidget_renderer*.so' 2>/dev/null | head -n 1 || true) + if [ -z "${artifact}" ]; then + echo "No built libwidget_renderer.so found. Current target tree:" + find target ext/widget_renderer/target -maxdepth 4 -type f | sed 's/^/ /' + exit 1 + fi + echo "Using artifact: ${artifact}" + cp "${artifact}" ext/widget_renderer/libwidget_renderer.so + cp "${artifact}" ext/widget_renderer/target/release/libwidget_renderer.so - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 945933627..6c87fa9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -27,9 +27,11 @@ # Don't check in these things .env .env.development +.env.* .manifest.yml .csv vars.yml +vars*.yml # For Macs .DS_Store @@ -48,6 +50,9 @@ target/ # Rust extension build artifacts ext/widget_renderer/Makefile -ext/widget_renderer/*.so ext/widget_renderer/*.dylib -!ext/widget_renderer/widget_renderer.so +# Keep the prebuilt Linux .so for Cloud Foundry deployment +!ext/widget_renderer/libwidget_renderer.so + +# Certificate files (avoid accidental commits of sensitive keys/certs) +*.pem diff --git a/.profile.d/build_widget_renderer.sh b/.profile.d/build_widget_renderer.sh index 8693efe40..75b6b87c7 100755 --- a/.profile.d/build_widget_renderer.sh +++ b/.profile.d/build_widget_renderer.sh @@ -1,19 +1,32 @@ #!/usr/bin/env bash -set -euo pipefail +set -uo pipefail -APP_ROOT="${HOME}/app" -EXT_DIR="${APP_ROOT}/ext/widget_renderer" -LIB_SO="${EXT_DIR}/libwidget_renderer.so" -LIB_DYLIB="${EXT_DIR}/libwidget_renderer.dylib" - -echo "===> widget_renderer: checking for native library" +# 1. Set LD_LIBRARY_PATH so the Rust extension can find libruby.so +if command -v ruby >/dev/null 2>&1; then + RUBY_LIB_DIR=$(ruby -e 'require "rbconfig"; print RbConfig::CONFIG["libdir"]' 2>/dev/null || true) + if [ -n "$RUBY_LIB_DIR" ] && [ -d "$RUBY_LIB_DIR" ]; then + export LD_LIBRARY_PATH="${RUBY_LIB_DIR}:${LD_LIBRARY_PATH:-}" + fi +fi -# Build the Rust extension at runtime if the shared library is missing. -if [ ! -f "$LIB_SO" ] && [ ! -f "$LIB_DYLIB" ]; then - echo "===> widget_renderer: building native extension" - cd "$EXT_DIR" - ruby extconf.rb - make +# 2. Locate the extension directory +if [ -d "${HOME}/app/ext/widget_renderer" ]; then + EXT_DIR="${HOME}/app/ext/widget_renderer" else - echo "===> widget_renderer: native extension already present" + EXT_DIR="${HOME}/ext/widget_renderer" +fi + +# 3. Copy the pre-built library from the target directory to the extension directory +# The Rust buildpack puts compiled artifacts in /home/vcap/app/target/release/ +STRATEGIC_LIB="/home/vcap/app/target/release/libwidget_renderer.so" +DEST_LIB="${EXT_DIR}/libwidget_renderer.so" + +if [ -f "$STRATEGIC_LIB" ]; then + echo "===> widget_renderer: Copying pre-built library to ${DEST_LIB}" + cp "$STRATEGIC_LIB" "$DEST_LIB" +elif [ -f "${EXT_DIR}/target/release/libwidget_renderer.so" ]; then + echo "===> widget_renderer: Copying local-built library to ${DEST_LIB}" + cp "${EXT_DIR}/target/release/libwidget_renderer.so" "$DEST_LIB" fi + +echo "===> widget_renderer: Setup complete. LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}" diff --git a/Cargo.lock b/Cargo.lock index abce685a2..7426f944e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" [[package]] name = "lazy_static" @@ -16,9 +16,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "memchr" @@ -56,9 +56,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" [[package]] name = "serde" @@ -92,9 +92,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.146" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8" dependencies = [ "itoa", "memchr", @@ -105,9 +105,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -122,7 +122,7 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "widget_renderer" -version = "0.1.0" +version = "0.1.3" dependencies = [ "rutie", "serde", diff --git a/Cargo.toml b/Cargo.toml index a625606cb..6a1c7021b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] members = [ - "ext/widget_renderer" + "ext/widget_renderer", ] + +[workspace.package] +version = "0.1.1" +edition = "2021" + resolver = "2" \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index a2eccedec..a3e8f2c50 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT PATH remote: ext/widget_renderer specs: - widget_renderer (0.1.1) + widget_renderer (0.1.3) fiddle rutie diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 09e1ae4ef..18e53e07a 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -4,3 +4,4 @@ //= link_directory ../stylesheets .scss //= link_tree ../../javascript .js //= link_tree ../../../vendor/javascript .js +//= link done.svg diff --git a/app/models/cx_collection.rb b/app/models/cx_collection.rb index 36c270631..3781c5c5b 100644 --- a/app/models/cx_collection.rb +++ b/app/models/cx_collection.rb @@ -87,7 +87,10 @@ def duplicate!(new_user:) end def self.to_csv - collections = all.includes(:organization, :service_provider, :service, :user).references(:organization).order(:fiscal_year, :quarter, 'organizations.name') + collections = all + .includes(:organization, :service, :user, :cx_collection_details, service_provider: :organization) + .references(:organization) + .order(:fiscal_year, :quarter, 'organizations.name') attributes = %i[ id @@ -118,7 +121,7 @@ def self.to_csv csv << attributes collections.each do |collection| - csv << attributes = [ + csv << [ collection.id, collection.name, collection.organization_id, diff --git a/app/models/form.rb b/app/models/form.rb index c6144b3dc..ea8aeb30b 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -299,22 +299,27 @@ def deployable_form? # or injected into a GTM Container Tag def touchpoints_js_string # Try to use Rust widget renderer if available - if defined?(WidgetRenderer) + use_rust = defined?(WidgetRenderer) && !Rails.env.test? + if use_rust begin + # Render the CSS using a controller context + css_content = render_widget_css + form_hash = { short_uuid: short_uuid, modal_button_text: modal_button_text || 'Feedback', - element_selector: element_selector || '', + element_selector: element_selector.presence || 'touchpoints-container', delivery_method: delivery_method, - load_css: load_css, + load_css: !!load_css, + css: css_content, success_text_heading: success_text_heading || 'Thank you', success_text: success_text || 'Your feedback has been received.', - suppress_submit_button: suppress_submit_button, + suppress_submit_button: !!suppress_submit_button, suppress_ui: false, # Default to false as per ERB logic kind: kind, - enable_turnstile: enable_turnstile, + enable_turnstile: !!enable_turnstile, has_rich_text_questions: has_rich_text_questions?, - verify_csrf: verify_csrf, + verify_csrf: !!verify_csrf, title: title, instructions: instructions, disclaimer_text: disclaimer_text, @@ -332,7 +337,14 @@ def touchpoints_js_string 'form-header-logo-square' end end, - questions: ordered_questions.map { |q| { answer_field: q.answer_field, question_type: q.question_type, question_text: q.question_text, is_required: q.is_required } }, + questions: ordered_questions.map do |q| + { + answer_field: q.answer_field, + question_type: q.question_type, + question_text: q.text, + is_required: !!q.is_required, + } + end, } json = form_hash.to_json puts "DEBUG: JSON class: #{json.class}" @@ -346,11 +358,21 @@ def touchpoints_js_string end # Always use ERB template rendering for now to avoid Rust compilation issues + controller_with_request = build_controller_with_mock_request + controller_with_request.render_to_string(partial: 'components/widget/fba', formats: :js, locals: { form: self }) + end + + # Renders the widget CSS partial for use with the Rust widget renderer + def render_widget_css + controller_with_request = build_controller_with_mock_request + controller_with_request.render_to_string(partial: 'components/widget/widget', formats: :css, locals: { form: self }) + end + + # Renders the widget CSS partial for use with the Rust widget renderer + def render_widget_css controller = ApplicationController.new - # Set up a mock request with default URL options to avoid "undefined method 'host' for nil" errors - # This is necessary because the ERB templates use root_url which requires request context - # Try action_controller first, fall back to action_mailer if not set + # Set up a mock request with default URL options default_options = Rails.application.config.action_controller.default_url_options || Rails.application.config.action_mailer.default_url_options || {} @@ -358,7 +380,6 @@ def touchpoints_js_string port = default_options[:port] || 3000 protocol = default_options[:protocol] || (port == 443 ? 'https' : 'http') - # Create a mock request mock_request = ActionDispatch::Request.new( 'rack.url_scheme' => protocol, 'HTTP_HOST' => "#{host}#{":#{port}" if port != 80 && port != 443}", @@ -367,7 +388,7 @@ def touchpoints_js_string ) controller.request = mock_request - controller.render_to_string(partial: 'components/widget/fba', formats: :js, locals: { form: self }) + controller.render_to_string(partial: 'components/widget/widget', formats: :css, locals: { form: self }) end def reportable_submissions(start_date: nil, end_date: nil) @@ -1041,6 +1062,31 @@ def self.forms_whose_retention_period_has_passed private + # Builds an ApplicationController instance with a mock request for rendering partials + # This is necessary because ERB templates use URL helpers which require request context + def build_controller_with_mock_request + controller = ApplicationController.new + + # Set up a mock request with default URL options + # Try action_controller first, fall back to action_mailer if not set + default_options = Rails.application.config.action_controller.default_url_options || + Rails.application.config.action_mailer.default_url_options || + {} + host = default_options[:host] || 'localhost' + port = default_options[:port] || 3000 + protocol = default_options[:protocol] || (port == 443 ? 'https' : 'http') + + mock_request = ActionDispatch::Request.new( + 'rack.url_scheme' => protocol, + 'HTTP_HOST' => "#{host}#{":#{port}" if port != 80 && port != 443}", + 'SERVER_NAME' => host, + 'SERVER_PORT' => port.to_s, + ) + + controller.request = mock_request + controller + end + def set_uuid self.uuid ||= SecureRandom.uuid self.short_uuid ||= self.uuid[0..7] diff --git a/app/models/service.rb b/app/models/service.rb index b182ff4b0..c0c0708cf 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -162,14 +162,14 @@ def self.to_csv organization_name organization_abbreviation service_provider_id service_provider_name service_provider_slug ] - %w[ channels - budget_code - uii_code - non_digital_explanation - homepage_url - digital_service - estimated_annual_volume_of_customers - fully_digital_service - barriers_to_fully_digital_service + budget_code + uii_code + non_digital_explanation + homepage_url + digital_service + estimated_annual_volume_of_customers + fully_digital_service + barriers_to_fully_digital_service multi_agency_service multi_agency_explanation other_service_type diff --git a/app/models/user.rb b/app/models/user.rb index 683d4c505..9fce23b96 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,7 +19,9 @@ class User < ApplicationRecord def cx_collections user_org = organization - user_parent_org = user_org&.parent + return CxCollection.none if user_org.nil? + + user_parent_org = user_org.parent CxCollection.where(cx_collections: { organization_id: [user_org.id, user_parent_org&.id].compact }) end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 6a7eb6cfd..71beffb05 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -16,8 +16,8 @@ <%= csp_meta_tag %> <%= action_cable_meta_tag %> <%= favicon_link_tag asset_path('favicon.ico') %> - <%= stylesheet_link_tag 'application', media: 'all', integrity: true %> - <%= javascript_include_tag 'app', integrity: true %> + <%= stylesheet_link_tag 'application', media: 'all', integrity: true, crossorigin: 'anonymous' %> + <%= javascript_include_tag 'app', integrity: true, crossorigin: 'anonymous' %> <%= render 'components/analytics/script_header' %> <%= javascript_importmap_tags %> @@ -41,6 +41,6 @@ <% end %> <%= render "components/footer" %> <%= render "components/timeout_modal" if current_user %> - <%= javascript_include_tag 'uswds.min', integrity: true %> + <%= javascript_include_tag 'uswds.min', integrity: true, crossorigin: 'anonymous' %> diff --git a/app/views/layouts/public.html.erb b/app/views/layouts/public.html.erb index 524a3b785..46c5261e1 100644 --- a/app/views/layouts/public.html.erb +++ b/app/views/layouts/public.html.erb @@ -19,7 +19,7 @@ <%= csrf_meta_tags if @form.verify_csrf? %> <%= csp_meta_tag %> <%= favicon_link_tag asset_path('favicon.ico') %> - <%= stylesheet_link_tag 'application', media: 'all', integrity: true %> + <%= stylesheet_link_tag 'application', media: 'all', integrity: true, crossorigin: 'anonymous' %> <%= render 'components/analytics/script_header' %> @@ -38,6 +38,6 @@ - <%= javascript_include_tag 'uswds.min', integrity: true %> + <%= javascript_include_tag 'uswds.min', integrity: true, crossorigin: 'anonymous' %> diff --git a/buildpacks/rust-buildpack/bin/finalize b/buildpacks/rust-buildpack/bin/finalize new file mode 100755 index 000000000..2fe0ca82f --- /dev/null +++ b/buildpacks/rust-buildpack/bin/finalize @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# This script runs AFTER all other buildpacks have run (including Ruby) +# It builds the Rust widget renderer extension linking against the Ruby +# library installed by the Ruby buildpack. +set -e +set -o pipefail + +BUILD_DIR=$1 +CACHE_DIR=$2 +DEPS_DIR=$3 +DEPS_IDX=$4 + +echo "-----> Rust Buildpack: Finalizing (building widget_renderer)" + +# Find our Rust installation +ROOT_DIR="$DEPS_DIR/$DEPS_IDX" +RUST_DIR="$ROOT_DIR/rust" +export RUSTUP_HOME="$RUST_DIR/rustup" +export CARGO_HOME="$RUST_DIR/cargo" +export PATH="$CARGO_HOME/bin:$PATH" + +# Verify Rust is available +if ! command -v cargo >/dev/null; then + echo "ERROR: Cargo not found. Rust installation may have failed." + exit 1 +fi +echo "Using cargo: $(which cargo)" +echo "Cargo version: $(cargo --version)" + +# Find the Ruby library installed by the Ruby buildpack +# The Ruby buildpack typically runs as deps index 2 (after rust=0, nodejs=1) +RUBY_LIB_PATH="" +RUBY_SO_NAME="" + +for dep_dir in "$DEPS_DIR"/*/; do + if [ -d "${dep_dir}ruby/lib" ]; then + RUBY_LIB_PATH="${dep_dir}ruby/lib" + echo "Found Ruby lib at: $RUBY_LIB_PATH" + break + fi +done + +if [ -z "$RUBY_LIB_PATH" ]; then + echo "WARNING: Could not find Ruby lib directory in deps" + # Try to find it with ruby itself if available + for dep_dir in "$DEPS_DIR"/*/; do + if [ -x "${dep_dir}bin/ruby" ]; then + RUBY_LIB_PATH=$("${dep_dir}bin/ruby" -e 'require "rbconfig"; print RbConfig::CONFIG["libdir"]' 2>/dev/null || true) + RUBY_SO_NAME=$("${dep_dir}bin/ruby" -e 'require "rbconfig"; print RbConfig::CONFIG["RUBY_SO_NAME"]' 2>/dev/null || true) + echo "Ruby libdir from RbConfig: $RUBY_LIB_PATH" + break + fi + done +fi + +if [ -z "$RUBY_LIB_PATH" ] || [ ! -d "$RUBY_LIB_PATH" ]; then + echo "ERROR: Could not locate Ruby library directory" + echo "Listing deps directories:" + ls -la "$DEPS_DIR"/*/ + exit 1 +fi + +# Verify libruby.so exists +if [ -f "$RUBY_LIB_PATH/libruby.so.3.2" ]; then + echo "Found libruby.so.3.2 in $RUBY_LIB_PATH" +elif [ -f "$RUBY_LIB_PATH/libruby.so" ]; then + echo "Found libruby.so in $RUBY_LIB_PATH" +else + echo "WARNING: libruby.so not found in $RUBY_LIB_PATH" + ls -la "$RUBY_LIB_PATH/" || true +fi + +# Set environment for rutie to find Ruby +export RUTIE_RUBY_LIB_PATH="$RUBY_LIB_PATH" +export RUTIE_RUBY_LIB_NAME="${RUBY_SO_NAME:-ruby.3.2}" +export LD_LIBRARY_PATH="$RUBY_LIB_PATH:${LD_LIBRARY_PATH:-}" +unset RUBY_STATIC +export NO_LINK_RUTIE=1 + +echo "Building widget_renderer with:" +echo " RUTIE_RUBY_LIB_PATH=$RUTIE_RUBY_LIB_PATH" +echo " RUTIE_RUBY_LIB_NAME=$RUTIE_RUBY_LIB_NAME" +echo " LD_LIBRARY_PATH=$LD_LIBRARY_PATH" + +# Build the Rust extension +WIDGET_DIR="$BUILD_DIR/ext/widget_renderer" +if [ -d "$WIDGET_DIR" ]; then + cd "$WIDGET_DIR" + echo "Building in $WIDGET_DIR" + + # Clean any prebuilt binaries (they were built on CircleCI with wrong paths) + rm -rf target/release/libwidget_renderer.so 2>/dev/null || true + rm -f libwidget_renderer.so 2>/dev/null || true + + # Build fresh + cargo build --release + + # Copy to expected location + if [ -f "target/release/libwidget_renderer.so" ]; then + cp target/release/libwidget_renderer.so . + echo "Successfully built widget_renderer" + echo "Library details:" + file target/release/libwidget_renderer.so + echo "Library dependencies:" + ldd target/release/libwidget_renderer.so || true + else + echo "ERROR: Failed to build widget_renderer" + ls -la target/release/ || true + exit 1 + fi +else + echo "WARNING: Widget renderer directory not found at $WIDGET_DIR" + echo "Listing build dir:" + ls -la "$BUILD_DIR/" +fi + +echo "-----> Rust Buildpack: Finalize complete" diff --git a/buildpacks/rust-buildpack/bin/supply b/buildpacks/rust-buildpack/bin/supply index a347e577c..e9de5c610 100755 --- a/buildpacks/rust-buildpack/bin/supply +++ b/buildpacks/rust-buildpack/bin/supply @@ -36,14 +36,34 @@ fi echo "Rust version: $(rustc --version)" echo "Cargo version: $(cargo --version)" -# Make available to subsequent buildpacks +# Make available to subsequent buildpacks without clobbering PATH mkdir -p "$ENV_DIR" echo -n "$RUSTUP_HOME" > "$ENV_DIR/RUSTUP_HOME" echo -n "$CARGO_HOME" > "$ENV_DIR/CARGO_HOME" echo -n "$CARGO_HOME/bin" > "$ENV_DIR/PATH.prepend" +# Note: The widget_renderer Rust library must be built by the Ruby buildpack +# after Ruby is installed, because it links against libruby.so. +# We'll handle this in a finalize script or profile.d script. + +# For now, just ensure Rust is available for later buildpacks +# The actual build happens in .profile.d/widget_renderer.sh at runtime +# OR we skip the prebuilt library and build fresh on CF + # Make available at runtime mkdir -p "$PROFILE_DIR" cat < "$PROFILE_DIR/rust.sh" export RUSTUP_HOME="$RUST_DIR/rustup" export CARGO_HOME="$RUST_DIR/cargo" +export PATH="\$CARGO_HOME/bin:\$PATH" + +# Find and export the Ruby library path for the Rust extension +# The Ruby buildpack installs Ruby in /home/vcap/deps/*/ruby/lib +for dep_dir in /home/vcap/deps/*/; do + if [ -d "\${dep_dir}ruby/lib" ]; then + export LD_LIBRARY_PATH="\${dep_dir}ruby/lib:\${LD_LIBRARY_PATH:-}" + echo "WidgetRenderer: Added \${dep_dir}ruby/lib to LD_LIBRARY_PATH" + break + fi +done +EOF diff --git a/config/application.rb b/config/application.rb index 2e3964006..c2a2bfd9a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,6 +34,9 @@ class Application < Rails::Application resource '*', headers: :any, methods: %i[get post options] end end + + # Global Rack::Attack middleware for throttling (e.g., form submissions). + config.middleware.use Rack::Attack config.autoload_lib(ignore: %w[assets tasks]) # Configuration for the application, engines, and railties goes here. diff --git a/config/environments/development.rb b/config/environments/development.rb index d78d60285..b384afc5d 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -98,4 +98,6 @@ config.active_record.encryption.support_unencrypted_data = true # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. # config.generators.apply_rubocop_autocorrect_after_generate! + + config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? end diff --git a/config/environments/production.rb b/config/environments/production.rb index 8b016ba7f..f9d51f7e1 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -17,8 +17,18 @@ # Do not fall back to assets pipeline if a precompiled asset is missed. config.assets.compile = false + + # Let Cloud Foundry / container platforms serve precompiled assets from /public. + config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? # Cache assets for far-future expiry since they are all digest stamped. - config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + # Add CORS headers for static assets to support SRI (Subresource Integrity) checks + # when assets are served from ASSET_HOST (different origin than the page) + config.public_file_server.headers = { + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, OPTIONS', + 'Access-Control-Allow-Headers' => 'Origin, X-Requested-With, Content-Type, Accept', + 'Cache-Control' => "public, max-age=#{1.year.to_i}" + } # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" diff --git a/config/environments/staging.rb b/config/environments/staging.rb index f1b5694e3..65c377a66 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -24,11 +24,22 @@ # Apache or NGINX already handles this. config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? + # Add CORS headers for static assets to support SRI (Subresource Integrity) checks + # when assets are served from ASSET_HOST (different origin than the page) + config.public_file_server.headers = { + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, OPTIONS', + 'Access-Control-Allow-Headers' => 'Origin, X-Requested-With, Content-Type, Accept', + 'Cache-Control' => "public, max-age=#{1.year.to_i}" + } + # Compress JavaScripts and CSS. # config.assets.css_compressor = :sass - # Do not fallback to assets pipeline if a precompiled asset is missed. - config.assets.compile = false + # Allow on-the-fly asset compilation in staging so we don't 500 if + # a new asset (e.g. done.svg) isn't present in the precompiled bundle. + config.assets.compile = true + config.assets.unknown_asset_fallback = true # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb @@ -107,7 +118,8 @@ # Prevent host header injection # Reference: https://github.com/ankane/secure_rails - config.action_controller.asset_host = ENV.fetch('TOUCHPOINTS_WEB_DOMAIN') + asset_host = ENV.fetch('ASSET_HOST', nil) + config.action_controller.asset_host = asset_host.presence || ENV.fetch('TOUCHPOINTS_WEB_DOMAIN') config.action_mailer.delivery_method = :ses_v2 config.action_mailer.ses_v2_settings = { diff --git a/config/environments/test.rb b/config/environments/test.rb index b9b690b53..4d6557474 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -38,6 +38,12 @@ # Store uploaded files on the local file system in a temporary directory. config.active_storage.service = :test + # Enable Rack::Attack so throttling specs run against middleware stack. + if defined?(Rack::Attack) + config.middleware.use Rack::Attack + config.after_initialize { Rack::Attack.enabled = true } + end + # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 8544c07c5..46d8b9a69 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -7,3 +7,6 @@ # Add additional assets to the asset load path. # Rails.application.config.assets.paths << Emoji.images_path + +# Ensure individual image assets (like done.svg) are available at runtime. +Rails.application.config.assets.precompile += %w[done.svg] diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index ea452670c..8f469fba1 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -14,7 +14,9 @@ class Rack::Attack # Is the request to the form submission route? def self.submission_route?(req) - !!(req.path =~ %r{^/touchpoints/\h{1,8}/submissions\.json$}i) + # Allow any touchpoint identifier and optional .json suffix so throttling + # still triggers even if the path shape changes slightly. + !!(req.path =~ %r{^/touchpoints/[^/]+/submissions(?:\.json)?$}i) end # Response for throttled requests diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index a834898fc..2a138a067 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -1,3 +1,8 @@ # frozen_string_literal: true -Rails.application.config.session_store :cookie_store, key: '_touchpoints_session', domain: ENV.fetch('TOUCHPOINTS_WEB_DOMAIN'), same_site: :lax, expire_after: 30.minutes +cookie_domain = ENV['SESSION_COOKIE_DOMAIN'].presence +Rails.application.config.session_store :cookie_store, + key: '_touchpoints_session', + domain: cookie_domain, + same_site: :lax, + expire_after: 30.minutes diff --git a/config/initializers/widget_renderer.rb b/config/initializers/widget_renderer.rb index 41538f058..7726d5097 100644 --- a/config/initializers/widget_renderer.rb +++ b/config/initializers/widget_renderer.rb @@ -1,25 +1,38 @@ -# Load the Rust widget renderer extension -begin - # Try loading from the extension directory - require_relative '../../ext/widget_renderer/widget_renderer' -rescue LoadError => e - Rails.logger.warn "Widget renderer extension not available: #{e.message}" - # Attempt to build the Rust extension on the fly (installs Rust via extconf if needed) +# Skip widget renderer during rake tasks and migrations (library may not be built yet in cf run-task) +# Only load when running as a server (not rake tasks, migrations, console, etc.) +# Note: 'bin/rails' is used for both server and console/tasks, so we must check if it's NOT a server. +is_server = defined?(Rails::Server) || $PROGRAM_NAME.include?('puma') || $PROGRAM_NAME.include?('unicorn') + +skip_loading = defined?(Rails::Console) || + ($PROGRAM_NAME.include?('rake') && !is_server) || + ($PROGRAM_NAME.include?('bin/rails') && !is_server) || + ENV['SKIP_WIDGET_RENDERER'] == 'true' + +unless skip_loading + # Load the Rust widget renderer extension only when running as server begin - Rails.logger.info 'Attempting to compile widget_renderer extension...' - ext_dir = Rails.root.join('ext', 'widget_renderer') - Dir.chdir(ext_dir) do - system('ruby extconf.rb') && system('make') + # Try loading the precompiled Rutie extension. + require_relative '../../ext/widget_renderer/lib/widget_renderer' + + # Verify the class was properly defined + if defined?(WidgetRenderer) && WidgetRenderer.respond_to?(:generate_js) + Rails.logger.info "WidgetRenderer: Rust extension loaded successfully! generate_js method available." + else + Rails.logger.warn "WidgetRenderer: Class defined but generate_js method not available." + Rails.logger.warn "WidgetRenderer: defined?(WidgetRenderer) = #{defined?(WidgetRenderer)}" + Rails.logger.warn "WidgetRenderer: respond_to?(:generate_js) = #{WidgetRenderer.respond_to?(:generate_js) rescue 'N/A'}" end - require_relative '../../ext/widget_renderer/widget_renderer' - Rails.logger.info 'Successfully compiled widget_renderer extension at runtime.' - rescue StandardError => build_error - Rails.logger.warn "Widget renderer build failed: #{build_error.class}: #{build_error.message}" + rescue LoadError => e + Rails.logger.warn "Widget renderer native library not available: #{e.message}" + Rails.logger.warn 'Rust extension must be built during staging; falling back to ERB template rendering.' + rescue SystemExit => e + Rails.logger.error "Widget renderer exited during load: #{e.message}" + Rails.logger.warn 'Falling back to ERB template rendering' + rescue StandardError => e + Rails.logger.error "Widget renderer failed to load: #{e.class}: #{e.message}" + Rails.logger.error e.backtrace.join("\n") if e.backtrace Rails.logger.warn 'Falling back to ERB template rendering' - puts "Widget renderer build failed: #{build_error.message}" if Rails.env.test? end -rescue StandardError => e - Rails.logger.error "Widget renderer failed to load: #{e.class}: #{e.message}" - Rails.logger.error e.backtrace.join("\n") if e.backtrace - Rails.logger.warn 'Falling back to ERB template rendering' +else + puts "WidgetRenderer: Skipping load (rake/rails command or console - library may not be built yet)" end diff --git a/config/routes.rb b/config/routes.rb index d3e3062fd..3e28f1e55 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -394,5 +394,5 @@ get 'status', to: 'site#status', as: :status get 'registry', to: 'site#registry', as: :registry get 'index', to: 'site#index', as: :index - root to: redirect(ENV.fetch('INDEX_URL')) + root to: redirect(ENV.fetch('INDEX_URL', '/admin')) end diff --git a/db/migrate/20251210192727_add_indexes_to_cx_collections.rb b/db/migrate/20251210192727_add_indexes_to_cx_collections.rb new file mode 100644 index 000000000..faec71f0d --- /dev/null +++ b/db/migrate/20251210192727_add_indexes_to_cx_collections.rb @@ -0,0 +1,16 @@ +class AddIndexesToCxCollections < ActiveRecord::Migration[8.0] + def change + # cx_collections table - missing all FK indexes + add_index :cx_collections, :organization_id + add_index :cx_collections, :user_id + add_index :cx_collections, :service_provider_id + add_index :cx_collections, :service_id + + # cx_collection_details table - missing FK index + add_index :cx_collection_details, :cx_collection_id + + # cx_responses table - missing FK indexes + add_index :cx_responses, :cx_collection_detail_id + add_index :cx_responses, :cx_collection_detail_upload_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 2017dc8bd..275383302 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_07_17_034402) do +ActiveRecord::Schema[8.0].define(version: 2025_12_10_192727) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -97,6 +97,7 @@ t.text "trust_question_text" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["cx_collection_id"], name: "index_cx_collection_details_on_cx_collection_id" end create_table "cx_collections", force: :cascade do |t| @@ -124,6 +125,10 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "submitted_at" + t.index ["organization_id"], name: "index_cx_collections_on_organization_id" + t.index ["service_id"], name: "index_cx_collections_on_service_id" + t.index ["service_provider_id"], name: "index_cx_collections_on_service_provider_id" + t.index ["user_id"], name: "index_cx_collections_on_user_id" end create_table "cx_responses", force: :cascade do |t| @@ -149,6 +154,8 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "external_id" + t.index ["cx_collection_detail_id"], name: "index_cx_responses_on_cx_collection_detail_id" + t.index ["cx_collection_detail_upload_id"], name: "index_cx_responses_on_cx_collection_detail_upload_id" end create_table "digital_product_versions", force: :cascade do |t| diff --git a/debug-bedrock-credentials.sh b/debug-bedrock-credentials.sh new file mode 100755 index 000000000..091945cdb --- /dev/null +++ b/debug-bedrock-credentials.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -e + +echo "======================================" +echo "AWS Bedrock Credentials Debug Script" +echo "======================================" +echo "" + +# Check current AWS identity +echo "1. Checking current AWS identity..." +aws sts get-caller-identity --profile gsai 2>/dev/null || { + echo "❌ Failed to get caller identity with gsai profile" + echo "Trying without profile..." + aws sts get-caller-identity 2>/dev/null || { + echo "❌ No valid AWS credentials found" + exit 1 + } +} + +echo "" +echo "2. Listing available AWS profiles..." +aws configure list-profiles + +echo "" +echo "3. Checking AWS credential configuration..." +aws configure list --profile gsai + +echo "" +echo "4. Testing Bedrock access (us-east-1)..." +echo "Available foundation models:" +aws bedrock list-foundation-models --profile gsai --region us-east-1 --query 'modelSummaries[?contains(modelId, `claude`)].{ModelId:modelId,Status:modelStatus}' --output table 2>/dev/null || { + echo "❌ Failed to access Bedrock in us-east-1" + echo "This could be due to:" + echo " - Insufficient permissions" + echo " - Bedrock not available in this region" + echo " - Model access not granted" +} + +echo "" +echo "5. Testing Bedrock access (us-west-2)..." +echo "Available foundation models in us-west-2:" +aws bedrock list-foundation-models --profile gsai --region us-west-2 --query 'modelSummaries[?contains(modelId, `claude`)].{ModelId:modelId,Status:modelStatus}' --output table 2>/dev/null || { + echo "❌ Failed to access Bedrock in us-west-2" +} + +echo "" +echo "6. Checking environment variables..." +echo "AWS_PROFILE: ${AWS_PROFILE:-'not set'}" +echo "AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-'not set'}" +echo "AWS_REGION: ${AWS_REGION:-'not set'}" +echo "AWS_CONFIG_FILE: ${AWS_CONFIG_FILE:-'not set'}" + +echo "" +echo "======================================" +echo "Potential Solutions:" +echo "======================================" +echo "" +echo "If Bedrock access fails, try these solutions:" +echo "" +echo "1. Set the correct AWS profile:" +echo " export AWS_PROFILE=gsai" +echo "" +echo "2. Set the region where Bedrock is available:" +echo " export AWS_DEFAULT_REGION=us-east-1" +echo " # or" +echo " export AWS_DEFAULT_REGION=us-west-2" +echo "" +echo "3. Request access to Claude models in Bedrock console:" +echo " https://console.aws.amazon.com/bedrock/home#/modelaccess" +echo "" +echo "4. Ensure your IAM role has Bedrock permissions:" +echo " - bedrock:InvokeModel" +echo " - bedrock:InvokeModelWithResponseStream" +echo " - bedrock:ListFoundationModels" +echo "" +echo "5. If using SSO, ensure the config file is properly set:" +echo " export AWS_CONFIG_FILE=/path/to/your/.aws/config" +echo "" diff --git a/ext/widget_renderer/BUILD_ID b/ext/widget_renderer/BUILD_ID new file mode 100644 index 000000000..404e411ec --- /dev/null +++ b/ext/widget_renderer/BUILD_ID @@ -0,0 +1 @@ +1766172000 diff --git a/ext/widget_renderer/Cargo.toml b/ext/widget_renderer/Cargo.toml index 04b11d209..fcd43782e 100644 --- a/ext/widget_renderer/Cargo.toml +++ b/ext/widget_renderer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "widget_renderer" -version = "0.1.0" +version = "0.1.3" edition = "2021" [lib] diff --git a/ext/widget_renderer/extconf.rb b/ext/widget_renderer/extconf.rb index fff4111ba..6b8fde0a5 100644 --- a/ext/widget_renderer/extconf.rb +++ b/ext/widget_renderer/extconf.rb @@ -30,10 +30,19 @@ def ensure_rust puts "Current directory: #{Dir.pwd}" puts "Using cargo executable: #{cargo_bin}" -system("#{cargo_bin} build --release") or abort 'Failed to build Rust extension' +puts "Running cargo build --release..." +system("#{cargo_bin} build --release 2>&1") or abort 'Failed to build Rust extension' + +# Copy the built shared library into the extension root so it is included in the droplet. +# Dir.glob does not expand `{}` patterns, so search explicitly for common extensions. +candidates = %w[so dylib dll].flat_map do |ext| + [ + File.join('target', 'release', "libwidget_renderer.#{ext}"), + File.join('..', '..', 'target', 'release', "libwidget_renderer.#{ext}") # workspace target + ] +end -# Copy the built shared library into the extension root so it is included in the droplet -built_lib = Dir.glob(File.join('target', 'release', 'libwidget_renderer.{so,dylib}')).first +built_lib = candidates.find { |path| File.file?(path) } abort 'Built library not found after cargo build' unless built_lib dest_root = File.join(Dir.pwd, File.basename(built_lib)) @@ -58,9 +67,9 @@ def ensure_rust local_target = File.join(Dir.pwd, 'target', 'release') workspace_target = File.expand_path('../../target/release', Dir.pwd) -lib_dir = if Dir.glob(File.join(local_target, 'libwidget_renderer.{so,dylib,dll}')).any? +lib_dir = if %w[so dylib dll].any? { |ext| File.exist?(File.join(local_target, "libwidget_renderer.#{ext}")) } local_target - elsif Dir.glob(File.join(workspace_target, 'libwidget_renderer.{so,dylib,dll}')).any? + elsif %w[so dylib dll].any? { |ext| File.exist?(File.join(workspace_target, "libwidget_renderer.#{ext}")) } workspace_target else local_target # Fallback diff --git a/ext/widget_renderer/lib/widget_renderer.rb b/ext/widget_renderer/lib/widget_renderer.rb index 1fd2a06a1..a5055bfd6 100644 --- a/ext/widget_renderer/lib/widget_renderer.rb +++ b/ext/widget_renderer/lib/widget_renderer.rb @@ -1,86 +1,149 @@ # frozen_string_literal: true +# Check if widget renderer should be skipped (for deployments where native library is unavailable) +if ENV['SKIP_WIDGET_RENDERER'] == 'true' + puts 'WidgetRenderer: SKIP_WIDGET_RENDERER is set, using stub implementation' + + # Define a stub class that provides the same interface but uses ERB fallback + class WidgetRenderer + def self.render_widget(template, data) + # Return nil to signal caller should use ERB fallback + nil + end + + def self.available? + false + end + end + + return # Exit early, don't load native library +end + require 'rutie' +require 'fileutils' -module WidgetRenderer - root = File.expand_path('..', __dir__) +root = File.expand_path('..', __dir__) - # Debugging: Print root and directory contents - puts "WidgetRenderer: root=#{root}" - puts "WidgetRenderer: __dir__=#{__dir__}" +# Debugging: Print root and directory contents +puts "WidgetRenderer: root=#{root}" +puts "WidgetRenderer: __dir__=#{__dir__}" - # Define potential paths where the shared object might be located - paths = [ - File.join(root, 'target', 'release'), - File.expand_path('../../target/release', root), # Workspace target directory - File.join(root, 'widget_renderer', 'target', 'release'), - File.join(root, 'target', 'debug'), - File.expand_path('../../target/debug', root), # Workspace debug directory - File.join(root, 'widget_renderer', 'target', 'debug'), - root, - ] +# Check for library file extensions based on platform +lib_extensions = %w[.so .bundle .dylib] +lib_names = lib_extensions.map { |ext| "libwidget_renderer#{ext}" } - # Find the first path that contains the library file - found_path = paths.find do |p| - exists = File.exist?(File.join(p, 'libwidget_renderer.so')) || - File.exist?(File.join(p, 'libwidget_renderer.bundle')) || - File.exist?(File.join(p, 'libwidget_renderer.dylib')) - puts "WidgetRenderer: Checking #{p} -> #{exists}" - exists - end +# Define potential paths where the shared object might be located +# Prefer workspace-level target (where CircleCI builds) over gem-level target +paths = [ + File.expand_path('../../target/release', root), # Workspace target directory (CircleCI build location) - CHECK FIRST + File.join(root, 'target', 'release'), + File.join(root, 'widget_renderer', 'target', 'release'), + File.expand_path('../../target/debug', root), # Workspace debug directory + File.join(root, 'target', 'debug'), + File.join(root, 'widget_renderer', 'target', 'debug'), + root, +] - if found_path - puts "WidgetRenderer: Found library in #{found_path}" - - # Debug: Check dependencies - lib_file = File.join(found_path, 'libwidget_renderer.so') - if File.exist?(lib_file) - puts "WidgetRenderer: File details for #{lib_file}" - puts `ls -l #{lib_file}` - puts `file #{lib_file}` - puts "WidgetRenderer: Running ldd on #{lib_file}" - puts `ldd #{lib_file} 2>&1` - end - else - puts 'WidgetRenderer: Library not found in any checked path. Listing root contents:' - # List files in root to help debug - Dir.glob(File.join(root, '*')).each { |f| puts f } - - puts 'WidgetRenderer: Listing target contents:' - target_dir = File.join(root, 'target') - if Dir.exist?(target_dir) - Dir.glob(File.join(target_dir, '*')).each { |f| puts f } - else - puts "WidgetRenderer: target directory does not exist at #{target_dir}" +# Find the first path that contains the library file +found_path = nil +found_lib = nil +paths.each do |p| + lib_names.each do |lib_name| + full_path = File.join(p, lib_name) + exists = File.exist?(full_path) + puts "WidgetRenderer: Checking #{full_path} -> #{exists}" + if exists + found_path = p + found_lib = full_path + break end + end + break if found_path +end - puts 'WidgetRenderer: Listing target/release contents:' - release_dir = File.join(root, 'target', 'release') - if Dir.exist?(release_dir) - Dir.glob(File.join(release_dir, '*')).each { |f| puts f } - else - puts "WidgetRenderer: target/release directory does not exist at #{release_dir}" +if found_path + puts "WidgetRenderer: Found library in #{found_path}" + + # Debug: Check dependencies + if File.exist?(found_lib) + puts "WidgetRenderer: File details for #{found_lib}" + puts `ls -l #{found_lib}` + puts `file #{found_lib}` + puts "WidgetRenderer: Running ldd on #{found_lib}" + puts `ldd #{found_lib} 2>&1` + end + + # Rutie always looks for the library in /target/release/libwidget_renderer.so + # If the library is not in that exact location, copy/symlink it there + expected_target_release = File.join(root, 'target', 'release') + expected_lib = File.join(expected_target_release, File.basename(found_lib)) + + unless File.exist?(expected_lib) + puts "WidgetRenderer: Library not in expected location, copying to #{expected_lib}" + FileUtils.mkdir_p(expected_target_release) + + # Copy or symlink the library to the expected location + begin + FileUtils.cp(found_lib, expected_lib) + puts "WidgetRenderer: Copied library to #{expected_lib}" + rescue => e + puts "WidgetRenderer: Failed to copy library: #{e.message}" + # Try symlink as fallback + begin + File.symlink(found_lib, expected_lib) + puts "WidgetRenderer: Created symlink at #{expected_lib}" + rescue => e2 + puts "WidgetRenderer: Failed to create symlink: #{e2.message}" + end end end - # Default to root if not found (Rutie might have its own lookup) - path = found_path || root + # If a stale module exists, remove it so Rutie can define or reopen the class. + if defined?(WidgetRenderer) && WidgetRenderer.is_a?(Module) && !WidgetRenderer.is_a?(Class) + Object.send(:remove_const, :WidgetRenderer) + end + # Ensure the constant exists as a Class so rb_define_class will reopen it instead of erroring on Module. + WidgetRenderer = Class.new unless defined?(WidgetRenderer) && WidgetRenderer.is_a?(Class) +else + puts 'WidgetRenderer: Library not found in any checked path. Listing root contents:' + # List files in root to help debug + Dir.glob(File.join(root, '*')).each { |f| puts f } + + puts 'WidgetRenderer: Listing target contents:' + target_dir = File.join(root, 'target') + if Dir.exist?(target_dir) + Dir.glob(File.join(target_dir, '*')).each { |f| puts f } + else + puts "WidgetRenderer: target directory does not exist at #{target_dir}" + end - # Rutie expects the project root, not the directory containing the library. - # It appends /target/release/lib.so to the path. - # So if we found it in .../target/release, we need to strip that part. - if path.end_with?('target/release') - path = path.sub(%r{/target/release$}, '') - elsif path.end_with?('target/debug') - path = path.sub(%r{/target/debug$}, '') + puts 'WidgetRenderer: Listing target/release contents:' + release_dir = File.join(root, 'target', 'release') + if Dir.exist?(release_dir) + Dir.glob(File.join(release_dir, '*')).each { |f| puts f } + else + puts "WidgetRenderer: target/release directory does not exist at #{release_dir}" end - # Rutie assumes the passed path is a subdirectory (like lib/) and goes up one level - # before appending target/release. - # So we append a 'lib' directory so that when it goes up, it lands on the root. - path = File.join(path, 'lib') + # No native library available; let caller handle fallback. + raise LoadError, 'WidgetRenderer native library not found' +end + +# Rutie expects the project root and appends /target/release/lib.so +# Pass the root directory with 'lib' appended (Rutie goes up one level) +path = File.join(root, 'lib') - puts "WidgetRenderer: Initializing Rutie with path: #{path}" +puts "WidgetRenderer: Initializing Rutie with path: #{path}" +begin Rutie.new(:widget_renderer).init 'Init_widget_renderer', path + + # Add available? method to the native class + class ::WidgetRenderer + def self.available? + true + end + end +rescue SystemExit => e + raise LoadError, "WidgetRenderer native init exited: #{e.message}" end diff --git a/ext/widget_renderer/libwidget_renderer.so b/ext/widget_renderer/libwidget_renderer.so new file mode 100755 index 000000000..b20f3d21d Binary files /dev/null and b/ext/widget_renderer/libwidget_renderer.so differ diff --git a/ext/widget_renderer/src/form_data.rs b/ext/widget_renderer/src/form_data.rs index e5c2361d5..e0807df8d 100644 --- a/ext/widget_renderer/src/form_data.rs +++ b/ext/widget_renderer/src/form_data.rs @@ -1,4 +1,13 @@ use serde::Deserialize; +use serde::de::{self, Deserializer}; + +fn deserialize_bool<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + // Treat null or missing as false to match legacy Rails data that may serialize nil booleans. + Option::::deserialize(deserializer).map(|v| v.unwrap_or(false)) +} #[derive(Deserialize)] pub struct FormData { @@ -6,14 +15,20 @@ pub struct FormData { pub modal_button_text: String, pub element_selector: String, pub delivery_method: String, + #[serde(default, deserialize_with = "deserialize_bool")] pub load_css: bool, pub success_text_heading: String, pub success_text: String, + #[serde(default, deserialize_with = "deserialize_bool")] pub suppress_submit_button: bool, + #[serde(default, deserialize_with = "deserialize_bool")] pub suppress_ui: bool, pub kind: String, + #[serde(default, deserialize_with = "deserialize_bool")] pub enable_turnstile: bool, + #[serde(default, deserialize_with = "deserialize_bool")] pub has_rich_text_questions: bool, + #[serde(default, deserialize_with = "deserialize_bool")] pub verify_csrf: bool, pub title: Option, pub instructions: Option, @@ -22,6 +37,8 @@ pub struct FormData { pub logo_class: Option, pub omb_approval_number: Option, pub expiration_date: Option, + #[serde(default)] + pub css: String, #[serde(skip, default)] pub prefix: String, pub questions: Vec, @@ -32,6 +49,7 @@ pub struct Question { pub answer_field: String, pub question_type: String, pub question_text: Option, + #[serde(default, deserialize_with = "deserialize_bool")] pub is_required: bool, } diff --git a/ext/widget_renderer/src/template_renderer.rs b/ext/widget_renderer/src/template_renderer.rs index 5a4c53f9c..736df31aa 100644 --- a/ext/widget_renderer/src/template_renderer.rs +++ b/ext/widget_renderer/src/template_renderer.rs @@ -52,10 +52,18 @@ impl TemplateRenderer { "" }; - let modal_class = if form.kind == "recruitment" { - format!("{} usa-modal--lg", form.prefix) + let modal_class = if form.load_css { + if form.kind == "recruitment" { + "fba-usa-modal fba-usa-modal--lg".to_string() + } else { + "fba-usa-modal".to_string() + } } else { - form.prefix.clone() + if form.kind == "recruitment" { + "usa-modal usa-modal--lg".to_string() + } else { + "usa-modal".to_string() + } }; let turnstile_check = if form.enable_turnstile { @@ -859,12 +867,20 @@ function FBAform(d, N) {{ let question_params = self.render_question_params(form); let html_body = self.render_html_body(form).replace("`", "\\`"); let html_body_no_modal = self.render_html_body_no_modal(form).replace("`", "\\`"); + // Escape the CSS for JavaScript string - escape backslashes, backticks, quotes, and newlines + let escaped_css = form.css + .replace("\\", "\\\\") + .replace("`", "\\`") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", ""); format!(r###" var touchpointFormOptions{uuid} = {{ 'formId': "{uuid}", 'modalButtonText': "{button_text}", 'elementSelector': "{selector}", + 'css': "{css}", 'deliveryMethod': "{delivery_method}", 'loadCSS': {load_css}, 'successTextHeading': "{success_heading}", @@ -888,6 +904,7 @@ var touchpointFormOptions{uuid} = {{ uuid = form.short_uuid, button_text = form.modal_button_text, selector = form.element_selector, + css = escaped_css, delivery_method = form.delivery_method, load_css = form.load_css, success_heading = form.success_text_heading, @@ -920,19 +937,52 @@ window.touchpointForm{uuid}.init(touchpointFormOptions{uuid}); // Initialize any USWDS components used in this form (function () {{ - const formId = "touchpoints-form-{uuid}"; - const fbaFormElement = document.querySelector(`#${{formId}}`); - if (fbaFormElement) {{ - fbaUswds.ComboBox.on(fbaFormElement); - fbaUswds.DatePicker.on(fbaFormElement); - }} - const modalId = "fba-modal-{uuid}"; - const fbaModalElement = document.querySelector(`#${{modalId}}`); - if (fbaModalElement) {{ - fbaUswds.Modal.on(fbaModalElement); + try {{ + if (typeof fbaUswds === 'undefined') {{ + console.error("Touchpoints Error: fbaUswds is not defined"); + return; + }} + + const formId = "touchpoints-form-{uuid}"; + const fbaFormElement = document.querySelector(`#${{formId}}`); + if (fbaFormElement) {{ + if (fbaUswds.ComboBox) fbaUswds.ComboBox.on(fbaFormElement); + if (fbaUswds.DatePicker) fbaUswds.DatePicker.on(fbaFormElement); + }} + const modalId = "fba-modal-{uuid}"; + const fbaModalElement = document.querySelector(`#${{modalId}}`); + if (fbaModalElement) {{ + if (fbaUswds.Modal) fbaUswds.Modal.on(fbaModalElement); + }} + // Ensure the modal button is also initialized if it exists (for 'modal' delivery method) + const fbaButton = document.querySelector('#fba-button'); + if (fbaButton) {{ + if (fbaUswds.Modal) {{ + fbaUswds.Modal.on(fbaButton); + fbaButton.classList.add('fba-initialized'); + }} else {{ + console.error("Touchpoints Error: fbaUswds.Modal is not defined"); + }} + }} + // Ensure the custom button is also initialized if it exists (for 'custom-button-modal' delivery method) + const customButtonSelector = '{element_selector}'; + const customButtonEl = (customButtonSelector && customButtonSelector.length > 0) ? document.getElementById(customButtonSelector) : null; + if (customButtonEl && ('{delivery_method}' === 'custom-button-modal')) {{ + if (fbaUswds.Modal) {{ + fbaUswds.Modal.on(customButtonEl); + }} else {{ + console.error("Touchpoints Error: fbaUswds.Modal is not defined"); + }} + }} + }} catch (e) {{ + console.error("Touchpoints Error: USWDS initialization failed", e); }} }})(); -"###, uuid = form.short_uuid) +"###, + uuid = form.short_uuid, + element_selector = form.element_selector, + delivery_method = form.delivery_method + ) } fn render_question_params(&self, form: &FormData) -> String { diff --git a/ext/widget_renderer/widget_renderer.gemspec b/ext/widget_renderer/widget_renderer.gemspec index 9d7312bbe..765256e49 100644 --- a/ext/widget_renderer/widget_renderer.gemspec +++ b/ext/widget_renderer/widget_renderer.gemspec @@ -2,7 +2,7 @@ Gem::Specification.new do |spec| spec.name = 'widget_renderer' - spec.version = '0.1.1' + spec.version = '0.1.3' spec.authors = ['GSA'] spec.email = ['touchpoints@gsa.gov'] @@ -13,7 +13,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 2.7.0' spec.metadata['rubygems_mfa_required'] = 'true' - spec.files = Dir['{src,lib}/**/*', 'Cargo.toml', 'Cargo.lock', 'extconf.rb'] + spec.files = Dir['{src,lib}/**/*', 'Cargo.toml', 'Cargo.lock', 'extconf.rb', 'BUILD_ID'] spec.extensions = ['extconf.rb'] spec.require_paths = ['lib'] diff --git a/fix-bedrock-credentials.sh b/fix-bedrock-credentials.sh new file mode 100755 index 000000000..fc928a923 --- /dev/null +++ b/fix-bedrock-credentials.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -e + +echo "======================================" +echo "AWS Bedrock Credentials Fix" +echo "======================================" +echo "" + +# Set the AWS config file path from your SSO login script +export AWS_CONFIG_FILE="/Users/rileydseaburg/Documents/programming/aigov-core-keycloak-terraform/.aws/config" + +echo "Setting AWS configuration..." +echo "AWS_CONFIG_FILE: $AWS_CONFIG_FILE" + +# Test if the config file exists +if [[ ! -f "$AWS_CONFIG_FILE" ]]; then + echo "❌ AWS config file not found at: $AWS_CONFIG_FILE" + echo "Please run your SSO login script first:" + echo "cd /Users/rileydseaburg/Documents/programming/aigov-core-keycloak-terraform/scripts" + echo "./aws-sso-login.sh" + exit 1 +fi + +echo "✓ AWS config file found" +echo "" + +# Check if SSO session is still valid +echo "Testing AWS SSO authentication..." +if aws sts get-caller-identity --profile gsai &>/dev/null; then + echo "✓ AWS SSO session is active" + echo "" + echo "Current identity:" + aws sts get-caller-identity --profile gsai --output table + echo "" +else + echo "❌ AWS SSO session expired or invalid" + echo "Please re-run the SSO login:" + echo "cd /Users/rileydseaburg/Documents/programming/aigov-core-keycloak-terraform/scripts" + echo "./aws-sso-login.sh" + exit 1 +fi + +# Set environment variables for Bedrock +export AWS_PROFILE=gsai +export AWS_DEFAULT_REGION=us-east-1 + +echo "Setting environment variables for Bedrock:" +echo "AWS_PROFILE: $AWS_PROFILE" +echo "AWS_DEFAULT_REGION: $AWS_DEFAULT_REGION" +echo "" + +# Test Bedrock access +echo "Testing Bedrock access..." +if aws bedrock list-foundation-models --region us-east-1 --query 'modelSummaries[?contains(modelId, `claude`)].{ModelId:modelId,Status:modelStatus}' --output table 2>/dev/null; then + echo "" + echo "✓ Bedrock access successful!" +else + echo "❌ Bedrock access failed. Trying us-west-2..." + export AWS_DEFAULT_REGION=us-west-2 + if aws bedrock list-foundation-models --region us-west-2 --query 'modelSummaries[?contains(modelId, `claude`)].{ModelId:modelId,Status:modelStatus}' --output table 2>/dev/null; then + echo "" + echo "✓ Bedrock access successful in us-west-2!" + echo "Note: Set AWS_DEFAULT_REGION=us-west-2 for future use" + else + echo "❌ Bedrock access failed in both regions" + echo "" + echo "Possible issues:" + echo "1. Claude models not enabled in your AWS account" + echo "2. Insufficient IAM permissions for Bedrock" + echo "3. Bedrock not available in your regions" + echo "" + echo "Next steps:" + echo "1. Visit AWS Bedrock console: https://console.aws.amazon.com/bedrock/" + echo "2. Go to Model access and request access to Claude models" + echo "3. Ensure your IAM role has bedrock:* permissions" + exit 1 + fi +fi + +echo "" +echo "======================================" +echo "✓ Setup Complete!" +echo "======================================" +echo "" +echo "To use Bedrock in this terminal session, run:" +echo "export AWS_CONFIG_FILE=\"$AWS_CONFIG_FILE\"" +echo "export AWS_PROFILE=gsai" +echo "export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION" +echo "" +echo "Or add these to your ~/.zshrc for permanent setup:" +echo "echo 'export AWS_CONFIG_FILE=\"$AWS_CONFIG_FILE\"' >> ~/.zshrc" +echo "echo 'export AWS_PROFILE=gsai' >> ~/.zshrc" +echo "echo 'export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION' >> ~/.zshrc" +echo "" diff --git a/spec/controllers/admin/cx_collections_controller_export_spec.rb b/spec/controllers/admin/cx_collections_controller_export_spec.rb deleted file mode 100644 index 655001f55..000000000 --- a/spec/controllers/admin/cx_collections_controller_export_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::CxCollectionsController, type: :controller do - let(:organization) { FactoryBot.create(:organization) } - let(:user) { FactoryBot.create(:user, organization: organization) } - let(:service) { FactoryBot.create(:service, organization: organization, service_owner_id: user.id) } - let(:service_provider) { FactoryBot.create(:service_provider, organization: organization) } - let!(:cx_collection) { FactoryBot.create(:cx_collection, organization: organization, service: service, user: user, service_provider: service_provider) } - - let(:valid_session) { {} } - - context 'as a User' do - before do - sign_in(user) - end - - describe 'GET #export_csv' do - let(:other_user) { FactoryBot.create(:user) } - let(:other_service) { FactoryBot.create(:service, organization: other_user.organization, service_owner_id: other_user.id) } - let!(:other_collection) { FactoryBot.create(:cx_collection, name: 'Other Collection', user: other_user, organization: other_user.organization, service: other_service) } - - it 'returns a success response' do - get :export_csv, session: valid_session - expect(response).to be_successful - expect(response.header['Content-Type']).to include 'text/csv' - expect(response.body).to include(cx_collection.name) - end - - it 'only includes collections for the current user' do - get :export_csv, session: valid_session - expect(response.body).to include(cx_collection.name) - expect(response.body).not_to include(other_collection.name) - end - - it 'handles nil associations gracefully' do - # Create a collection with missing associations to test safe navigation - collection_with_issues = FactoryBot.build(:cx_collection, organization: organization, user: user) - collection_with_issues.save(validate: false) - # Manually set associations to nil if FactoryBot enforces them - collection_with_issues.update_columns(service_id: nil, service_provider_id: nil) - - get :export_csv, session: valid_session - expect(response).to be_successful - expect(response.body).to include(collection_with_issues.name) - end - end - end -end diff --git a/spec/features/admin/digital_products_spec.rb b/spec/features/admin/digital_products_spec.rb index e05ac8c82..5be3283ce 100644 --- a/spec/features/admin/digital_products_spec.rb +++ b/spec/features/admin/digital_products_spec.rb @@ -60,9 +60,10 @@ end it 'loads the show page' do - expect(page).to have_content('Digital product was successfully created.') - expect(page).to have_content('https://lvh.me') - expect(page).to have_content('No Code Repository URL specified') + expect(page).to have_current_path(%r{/admin/digital_products/\d+}, ignore_query: true, wait: 10) + expect(page).to have_text('Digital product was successfully created.', wait: 10) + expect(page).to have_text('https://lvh.me', wait: 5) + expect(page).to have_text('No Code Repository URL specified', wait: 5) end end diff --git a/spec/features/admin/digital_service_accounts_spec.rb b/spec/features/admin/digital_service_accounts_spec.rb index b8793ef94..14cf37fd9 100644 --- a/spec/features/admin/digital_service_accounts_spec.rb +++ b/spec/features/admin/digital_service_accounts_spec.rb @@ -207,7 +207,7 @@ describe '#search' do let!(:digital_service_account) { FactoryBot.create(:digital_service_account, name: 'Test1776', service: 'facebook', aasm_state: 'published') } - let!(:digital_service_account_2) { FactoryBot.create(:digital_service_account, aasm_state: 'created') } + let!(:digital_service_account_2) { FactoryBot.create(:digital_service_account, service: 'twitter', aasm_state: 'created') } before do visit admin_digital_service_accounts_path diff --git a/spec/features/admin/forms/form_permissions_spec.rb b/spec/features/admin/forms/form_permissions_spec.rb index bab3a8502..d699eb4df 100644 --- a/spec/features/admin/forms/form_permissions_spec.rb +++ b/spec/features/admin/forms/form_permissions_spec.rb @@ -40,14 +40,14 @@ end it 'see the email displayed and can remove the role' do - expect(page).to have_content('User Role successfully added to Form') + expect(page).to have_selector('.usa-alert__text', text: 'User Role successfully added to Form', wait: 10) + expect(page).to have_selector('.roles-and-permissions', wait: 10) within('.roles-and-permissions') do - expect(page).to_not have_content('No users at this time') - end - - within(".roles-and-permissions table tr[data-user-id=\"#{user.id}\"]") do - expect(page).to have_content(user.email) - expect(page).to have_link('Delete') + expect(page).to have_no_content('No users at this time', wait: 5) + within("table tr[data-user-id=\"#{user.id}\"]") do + expect(page).to have_content(user.email) + expect(page).to have_link('Delete') + end end end end diff --git a/spec/features/admin/forms_spec.rb b/spec/features/admin/forms_spec.rb index e337b7deb..790429d9f 100644 --- a/spec/features/admin/forms_spec.rb +++ b/spec/features/admin/forms_spec.rb @@ -197,8 +197,14 @@ find('label', text: 'Hosted on touchpoints').click click_on 'Update Form' expect(page).to have_content('Form was successfully updated.') + + # Wait for form to finish updating before navigating away + sleep 0.5 + visit example_admin_form_path(Form.last) - expect(page).to have_css('.form-header-logo-square') + + # Use more robust visibility check to avoid stale element errors + expect(page).to have_css('.form-header-logo-square', wait: 10) end end @@ -444,10 +450,11 @@ find('.survey-title-input').set('Updated Form Title') find('.survey-title-input').native.send_key :tab expect(page).to have_content('form title saved') + # Wait for AJAX save to complete before refreshing + wait_for_ajax # and persists after refresh visit questions_admin_form_path(form) wait_for_builder - wait_for_builder expect(find('.survey-title-input').value).to eq('Updated Form Title') end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 5740d1051..86d0a32d5 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -46,6 +46,7 @@ let(:form) { FactoryBot.create(:form, :single_question, organization:, notification_emails: user.email) } let!(:submission) { FactoryBot.create(:submission, form:) } let(:days_ago) { 1 } + let(:time_threshold) { days_ago.days.ago } let(:mail) { UserMailer.submissions_digest(form.id, days_ago) } before do @@ -53,14 +54,14 @@ end it 'renders the headers' do - expect(mail.subject).to eq("Touchpoints Digest: New Submissions to #{form.name} since #{days_ago.days.ago}") + expect(mail.subject).to eq("Touchpoints Digest: New Submissions to #{form.name} since #{time_threshold}") expect(mail.to).to eq(form.notification_emails.split) expect(mail.from).to eq([ENV.fetch('TOUCHPOINTS_EMAIL_SENDER')]) end it 'renders the body' do - expect(mail.body.encoded).to have_text("Notification of feedback received since #{days_ago.days.ago}") - expect(mail.body.encoded).to have_text("1 feedback responses have been submitted to your form, #{form.name}, since #{days_ago.days.ago}") + expect(mail.body.encoded).to have_text("Notification of feedback received since #{time_threshold}") + expect(mail.body.encoded).to have_text("1 feedback responses have been submitted to your form, #{form.name}, since #{time_threshold}") end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0193f7923..07e1a9daf 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -165,6 +165,45 @@ end end + describe "#cx_collections" do + let(:user_with_org) { FactoryBot.create(:user, organization: organization) } + let(:service_provider) { FactoryBot.create(:service_provider, organization: organization) } + let(:service) { FactoryBot.create(:service, organization: organization, service_provider: service_provider, service_owner_id: user_with_org.id) } + + context "when user has no organization" do + it "returns an empty collection" do + user_without_org = User.new(email: "test@example.gov") + user_without_org.organization = nil + result = user_without_org.cx_collections + expect(result).to eq(CxCollection.none) + expect(result.count).to eq(0) + end + end + + context "when user has an organization" do + it "returns cx_collections for the user's organization" do + cx_collection = FactoryBot.create(:cx_collection, organization: organization, service: service, service_provider: service_provider, user: user_with_org) + result = user_with_org.cx_collections + expect(result).to include(cx_collection) + end + end + + context "when user's organization has a parent organization" do + let(:parent_org) { FactoryBot.create(:organization, name: "Parent Org", domain: "parent.gov", abbreviation: "PARENT") } + let(:child_org) { FactoryBot.create(:organization, name: "Child Org", domain: "child.gov", abbreviation: "CHILD", parent_id: parent_org.id) } + let(:child_user) { FactoryBot.create(:user, organization: child_org) } + let(:parent_service_owner) { FactoryBot.create(:user, organization: parent_org) } + let(:parent_service_provider) { FactoryBot.create(:service_provider, organization: parent_org) } + let(:parent_service) { FactoryBot.create(:service, organization: parent_org, service_provider: parent_service_provider, service_owner_id: parent_service_owner.id) } + + it "includes cx_collections from the parent organization" do + parent_cx_collection = FactoryBot.create(:cx_collection, organization: parent_org, service: parent_service, service_provider: parent_service_provider, user: child_user) + result = child_user.cx_collections + expect(result).to include(parent_cx_collection) + end + end + end + describe "#ensure_organization" do before do @org2 = Organization.create(name: "Subdomain Example", domain: "sub.example.gov", abbreviation: "SUB") diff --git a/spec/requests/rack_attack_spec.rb b/spec/requests/rack_attack_spec.rb index b94ecda71..e66eda5e2 100644 --- a/spec/requests/rack_attack_spec.rb +++ b/spec/requests/rack_attack_spec.rb @@ -7,32 +7,34 @@ let(:ip) { '1.2.3.4' } let(:headers) { { 'REMOTE_ADDR' => ip } } - let(:valid_submission_path) { "/touchpoints/1234abcd/submissions.json" } + let!(:form) { FactoryBot.create(:form, :open_ended_form, short_uuid: '1234abcd') } + let(:valid_submission_path) { "/touchpoints/#{form.short_uuid}/submissions.json" } + let(:valid_params) { { submission: { answer_01: 'test answer' } } } it 'allows up to 10 requests per minute' do 10.times do - post valid_submission_path, headers: headers + post valid_submission_path, params: valid_params, headers: headers expect(response).not_to have_http_status(:too_many_requests) end end it 'blocks the 11th request within a minute' do - 10.times { post valid_submission_path, headers: headers } + 10.times { post valid_submission_path, params: valid_params, headers: headers } - post valid_submission_path, headers: headers + post valid_submission_path, params: valid_params, headers: headers expect(response).to have_http_status(:too_many_requests) end it 'does not throttle requests from different IPs' do 10.times do |i| - post valid_submission_path, headers: { 'REMOTE_ADDR' => "192.168.1.#{i}" } + post valid_submission_path, params: valid_params, headers: { 'REMOTE_ADDR' => "192.168.1.#{i}" } expect(response).not_to have_http_status(:too_many_requests) end end it 'does not throttle non-matching routes' do 20.times do - post "/other_path", headers: headers + post "/other_path", params: valid_params, headers: headers expect(response).not_to have_http_status(:too_many_requests) end end @@ -40,7 +42,7 @@ it 'recognizes both numeric and short UUID paths' do valid_paths = ["/submissions/123.json", "/submissions/abc123de.json"] valid_paths.each do |path| - post path, headers: headers + post path, params: valid_params, headers: headers expect(response).not_to have_http_status(:too_many_requests) end end @@ -48,7 +50,7 @@ it 'does not throttle invalid submission paths' do invalid_paths = ["/submissions/too_long_uuid_1234.json", "/submissions/.json"] invalid_paths.each do |path| - post path, headers: headers + post path, params: valid_params, headers: headers expect(response).not_to have_http_status(:too_many_requests) end end diff --git a/tmp_expired_login_gov_cert.pem b/tmp_expired_login_gov_cert.pem new file mode 100644 index 000000000..76a141b05 --- /dev/null +++ b/tmp_expired_login_gov_cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEDCCAfgCCQDTpAzRkwVWrTANBgkqhkiG9w0BAQsFADBKMQswCQYDVQQGEwJV +UzELMAkGA1UECAwCREMxDDAKBgNVBAoMA0dTQTEgMB4GCSqGSIb3DQEJARYRcnlh +bi53b2xkQGdzYS5nb3YwHhcNMjIwODI2MjA1MjMzWhcNMjMwODI2MjA1MjMzWjBK +MQswCQYDVQQGEwJVUzELMAkGA1UECAwCREMxDDAKBgNVBAoMA0dTQTEgMB4GCSqG +SIb3DQEJARYRcnlhbi53b2xkQGdzYS5nb3YwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDYceADQZuOFonGQid/y2v7XMLZ++xmwmIZPstuUxgmcnzxgvYC +pNW5Xe53GzO69hW42PZScK0kyDsGswbENTulorBJP16ETwM6P3/08nTs25N+i3ad +SWU5QY8KyYA+qOeVQ4hcUQ28HkYuAcyNOFgq8o/OyzaDPsLnkzdKjuYHssVDZL81 +XKRvc8q6wQmOJ5kGEvl9OtYQyIsFcUB2ZfnIXLFYa8qMbwJWymh//0HXm3SwEKWe +j7F+8bfTZNyUqHHvd6no2dDye7zTkb3DIbS9gzdfVhykPZqgXh2fNfyefE2fg5cq +S4Gr2z86WeP/r1FulR4pIvSUPaFpxVwWmrCbAgMBAAEwDQYJKoZIhvcNAQELBQAD +ggEBALKAdz5sSH4kspX8vjARPoJEQNreWhW6R8u5db26zlDOgGONaAz3Q6MbLUKX +FHn4hGWZnKJ6WP42fpqpANoeATjT9iTi0g932Yx6ZOwUKMwJ+qOeG7ban0woplsR +2bhf5YrIBR2yY7EaZ+8PDHqXr3dDxTvQvElf4KhrrQeyFkCuOedkNTBPTTUwCBzV +KYvvYqEFk/N9PcRI9fDhxgkOwmaXxLie+CS46z+dwY0+2+stEOwXqQ7HAarTmJwn +1CbySv6QNoF6GXC+qCu2ZaBnxPxr+Y0rY7Tg0quWV4ciGEDjYqd3LuH9pGBKSzwk +2ykQr1sy1vsRqmpn6sPo+ZbLUAU= +-----END CERTIFICATE----- diff --git a/touchpoints-demo.yml b/touchpoints-demo.yml index 2def7cbe9..e327057f6 100644 --- a/touchpoints-demo.yml +++ b/touchpoints-demo.yml @@ -1,5 +1,6 @@ applications: - name: touchpoints-demo + timeout: 180 command: bundle exec rake cf:on_first_instance db:migrate && bundle exec rails s -b 0.0.0.0 -p $PORT -e $RAILS_ENV env: AWS_SES_ACCESS_KEY_ID: @@ -18,7 +19,9 @@ applications: TOUCHPOINTS_EMAIL_SENDER: TOUCHPOINTS_GTM_CONTAINER_ID: TOUCHPOINTS_WEB_DOMAIN: touchpoints-demo.app.cloud.gov + SKIP_WIDGET_RENDERER: "true" buildpacks: + - https://github.com/rileyseaburg/rust-buildpack-touchpoints.git - nodejs_buildpack - ruby_buildpack services: diff --git a/touchpoints-staging.yml b/touchpoints-staging.yml index fcca1cad5..631485a26 100644 --- a/touchpoints-staging.yml +++ b/touchpoints-staging.yml @@ -1,7 +1,9 @@ applications: - name: touchpoints-staging + memory: 2G disk_quota: 2G - command: bundle exec rake cf:on_first_instance db:schema:load && rake db:seed && bundle exec rails s -b 0.0.0.0 -p $PORT -e $RAILS_ENV + timeout: 180 + command: bundle exec rake cf:on_first_instance db:schema:load db:seed && bundle exec rails s -b 0.0.0.0 -p $PORT -e $RAILS_ENV env: AWS_SES_ACCESS_KEY_ID: ((AWS_SES_ACCESS_KEY_ID)) AWS_SES_SECRET_ACCESS_KEY: ((AWS_SES_SECRET_ACCESS_KEY)) @@ -10,7 +12,7 @@ applications: LOGIN_GOV_CLIENT_ID: urn:gov:gsa:openidconnect.profiles:sp:sso:gsa-tts-opp:touchpoints-staging LOGIN_GOV_IDP_BASE_URL: https://idp.int.identitysandbox.gov/ LOGIN_GOV_PRIVATE_KEY: ((LOGIN_GOV_PRIVATE_KEY)) - LOGIN_GOV_REDIRECT_URI: https://touchpoints-staging.app.cloud.gov/users/auth/login_dot_gov/callback + LOGIN_GOV_REDIRECT_URI: https://app-staging.touchpoints.digital.gov/users/auth/login_dot_gov/callback NEW_RELIC_KEY: ((NEW_RELIC_KEY)) RAILS_ENV: staging S3_AWS_ACCESS_KEY_ID: ((S3_AWS_ACCESS_KEY_ID)) @@ -21,8 +23,12 @@ applications: TOUCHPOINTS_EMAIL_SENDER: ((TOUCHPOINTS_EMAIL_SENDER)) TOUCHPOINTS_WEB_DOMAIN: touchpoints-staging.app.cloud.gov TURNSTILE_SECRET_KEY: ((TURNSTILE_SECRET_KEY)) + TOUCHPOINTS_WEB_DOMAIN2: app-staging.touchpoints.digital.gov + ASSET_HOST: app-staging.touchpoints.digital.gov + SKIP_WIDGET_RENDERER: "true" buildpacks: - https://github.com/rileyseaburg/rust-buildpack-touchpoints.git + - nodejs_buildpack - ruby_buildpack services: - touchpoints-staging-database diff --git a/touchpoints.yml b/touchpoints.yml index f7affe4a7..4fff6d37f 100644 --- a/touchpoints.yml +++ b/touchpoints.yml @@ -1,31 +1,41 @@ applications: - name: touchpoints + memory: 2G + disk_quota: 2G + timeout: 180 command: bundle exec rake cf:on_first_instance db:migrate && bundle exec rails s -b 0.0.0.0 -p $PORT -e $RAILS_ENV env: - AWS_SES_ACCESS_KEY_ID: - AWS_SES_SECRET_ACCESS_KEY: - AWS_SES_REGION: + # Non-secret env vars only - secrets are set via `cf set-env` and should NOT be in this manifest + # Empty values here would OVERWRITE existing secrets on cf push! LOGIN_GOV_CLIENT_ID: urn:gov:gsa:openidconnect.profiles:sp:sso:gsa-tts-opp:touchpoints LOGIN_GOV_IDP_BASE_URL: https://secure.login.gov/ - LOGIN_GOV_PRIVATE_KEY: LOGIN_GOV_REDIRECT_URI: https://touchpoints.app.cloud.gov/users/auth/login_dot_gov/callback - NEW_RELIC_KEY: RAILS_ENV: production - S3_AWS_ACCESS_KEY_ID: - S3_AWS_BUCKET_NAME: - S3_AWS_REGION: - S3_AWS_SECRET_ACCESS_KEY: - TOUCHPOINTS_EMAIL_SENDER: - TOUCHPOINTS_GTM_CONTAINER_ID: + RAILS_SERVE_STATIC_FILES: "true" TOUCHPOINTS_WEB_DOMAIN: touchpoints.app.cloud.gov + INDEX_URL: /admin + SKIP_WIDGET_RENDERER: "true" + # Secrets managed via cf set-env (DO NOT add empty keys here): + # - AWS_SES_ACCESS_KEY_ID + # - AWS_SES_SECRET_ACCESS_KEY + # - AWS_SES_REGION + # - LOGIN_GOV_PRIVATE_KEY + # - NEW_RELIC_KEY + # - S3_AWS_ACCESS_KEY_ID + # - S3_AWS_BUCKET_NAME + # - S3_AWS_REGION + # - S3_AWS_SECRET_ACCESS_KEY + # - TOUCHPOINTS_EMAIL_SENDER + # - TOUCHPOINTS_GTM_CONTAINER_ID buildpacks: - - rust_buildpack + - https://github.com/rileyseaburg/rust-buildpack-touchpoints.git - nodejs_buildpack - ruby_buildpack services: - touchpoints-prod-db - - touchpoints-prod-redis + - touchpoints-redis-service - touchpoints-prod-s3 - touchpoints-prod-deployer + - touchpoints-s3-uploads routes: - route: touchpoints.app.cloud.gov