diff --git a/.circleci/config.yml b/.circleci/config.yml index 95af21f9b3..1eab89a74c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -167,7 +167,6 @@ commands: else echo "Slack notification sent successfully" fi - notify_slack_deploy: parameters: slack_bot_token: @@ -248,6 +247,14 @@ commands: description: "Name of Cloud Foundry cloud.gov application; must match application name specified in manifest" type: string + build_branch: + description: "The branch of the build being deployed" + type: string + default: << pipeline.git.branch >> + build_commit: + description: "The commit of the build being deployed" + type: string + default: << pipeline.git.revision >> auth_client_id: description: "Name of CircleCi project environment variable that holds authentication client id, a required application variable" @@ -349,6 +356,7 @@ commands: - run: name: Push application with deployment vars command: | + set -x cf push \ --vars-file << parameters.deploy_config_file >> \ --var AUTH_CLIENT_ID=${<< parameters.auth_client_id >>} \ @@ -373,39 +381,53 @@ commands: --var ITAMS_MD_PORT=${<< parameters.itams_md_port >>} \ --var ITAMS_MD_USERNAME=${<< parameters.itams_md_username >>} \ --var ITAMS_MD_PASSWORD=${<< parameters.itams_md_password >>} \ - --var SMARTSHEET_ACCESS_TOKEN=${<< parameters.smartsheet_access_token >>} + --var SMARTSHEET_ACCESS_TOKEN=${<< parameters.smartsheet_access_token >>} \ + --var BUILD_BRANCH=<< parameters.build_branch >> \ + --var BUILD_COMMIT=<< parameters.build_commit >> \ + --var BUILD_NUMBER=<< pipeline.number >> \ + --var BUILD_TIMESTAMP="$(date +"%Y-%m-%d %H:%M:%S")" # - run: # name: Push maintenance application # command: | # cd maintenance_page && cf push -s cflinuxfs4 --vars-file ../<> - cf_backup: - description: "Login to cloud foundry space with service account credentials, Connect to DB & S3, backup DB to S3" + cf_automation_task: + description: "Login to Cloud Foundry space, run automation task, and send notification" parameters: auth_client_secret: - description: "Name of CircleCi project environment variable that - holds authentication client secret, a required application variable" + description: "Name of CircleCi project environment variable that holds authentication client secret" type: env_var_name cloudgov_username: - description: "Name of CircleCi project environment variable that - holds deployer username for cloudgov space" + description: "Name of CircleCi project environment variable that holds deployer username for Cloud Foundry space" type: env_var_name cloudgov_password: - description: "Name of CircleCi project environment variable that - holds deployer password for cloudgov space" + description: "Name of CircleCi project environment variable that holds deployer password for Cloud Foundry space" type: env_var_name cloudgov_space: - description: "Name of CircleCi project environment variable that - holds name of cloudgov space to target for application deployment" + description: "Name of CircleCi project environment variable that holds name of Cloud Foundry space to target for application deployment" type: env_var_name - rds_service_name: - description: "Name of the rds service to backup" + task_name: + description: "Name of the automation task to run" + type: string + task_command: + description: "Command to run for the automation task" + type: string + task_args: + description: "Arguments for the automation task" + type: string + config: + description: "Config prefix for the automation task" + type: string + success_message: + description: "Success message for Slack notification" type: string - s3_service_name: - description: "Name of the s3 service access" + timeout: + description: "Max duration allowed for task" type: string - backup_prefix: - description: "prefix name to use for backups" + default: "300" + directory: + description: 'directory to root to push' type: string + default: "./automation" steps: - run: name: Install Dependencies @@ -456,18 +478,28 @@ commands: name: Start Log Monitoring command: | #!/bin/bash + CONTROL_FILE="/tmp/stop_tail" rm -f $CONTROL_FILE - # Start tailing logs - cf logs tta-automation & + # Function to start tailing logs + start_log_tailing() { + echo "Starting cf logs for tta-automation..." + cf logs tta-automation & + TAIL_PID=$! + } - # Get the PID of the cf logs command - TAIL_PID=$! + # Start tailing logs for the first time + start_log_tailing - # Wait for the control file to be created + # Monitor the cf logs process while [ ! -f $CONTROL_FILE ]; do - sleep 1 + # Check if the cf logs process is still running + if ! kill -0 $TAIL_PID 2>/dev/null; then + echo "cf logs command has stopped unexpectedly. Restarting..." + start_log_tailing + fi + sleep 1 done # Kill the cf logs command @@ -475,20 +507,24 @@ commands: echo "cf logs command for tta-automation has been terminated." background: true - run: - name: cf_lambda - script to trigger backup + name: cf_lambda - script to trigger task command: | set -x json_data=$(jq -n \ - --arg automation_dir "./automation" \ - --arg manifest "manifest.yml" \ - --arg task_name "backup" \ - --arg command "cd /home/vcap/app/db-backup/scripts; bash ./db_backup.sh" \ - --argjson args '["<< parameters.backup_prefix >>", "<< parameters.rds_service_name >>", "<< parameters.s3_service_name >>"]' \ + --arg directory "<< parameters.directory >>" \ + --arg config "<< parameters.config >>" \ + --arg task_name "<< parameters.task_name >>" \ + --arg command "<< parameters.task_command >>" \ + --arg timeout_active_tasks "<< parameters.timeout >>" \ + --arg timeout_ensure_app_stopped "<< parameters.timeout >>" \ + --argjson args '<< parameters.task_args >>' \ '{ - automation_dir: $automation_dir, - manifest: $manifest, + directory: $directory, + config: $config, task_name: $task_name, command: $command, + timeout_active_tasks: $timeout_active_tasks, + timeout_ensure_app_stopped: $timeout_ensure_app_stopped, args: $args }') @@ -496,17 +532,14 @@ commands: find ./automation -name "*.sh" -exec chmod +x {} \; ./automation/ci/scripts/cf_lambda.sh "$json_data" - environment: - CF_RDS_SERVICE_NAME: ttahub-prod - CF_S3_SERVICE_NAME: ttahub-db-backups - run: name: Generate Message command: | if [ ! -z "$CIRCLE_PULL_REQUEST" ]; then PR_NUMBER=${CIRCLE_PULL_REQUEST##*/} - echo ":download::database: Production backup before PR <$CIRCLE_PULL_REQUEST|$PR_NUMBER> successful!" > /tmp/message_file + echo "<< parameters.success_message >> before PR <$CIRCLE_PULL_REQUEST|$PR_NUMBER> successful!" > /tmp/message_file else - echo ":download::database: Production backup successful!" > /tmp/message_file + echo "<< parameters.success_message >> successful!" > /tmp/message_file fi - notify_slack: slack_bot_token: $SLACK_BOT_TOKEN @@ -524,7 +557,76 @@ commands: # Logout from Cloud Foundry cf logout - + cf_backup: + description: "Backup database to S3" + parameters: + auth_client_secret: { type: env_var_name } + cloudgov_username: { type: env_var_name } + cloudgov_password: { type: env_var_name } + cloudgov_space: { type: env_var_name } + rds_service_name: { type: string } + s3_service_name: { type: string } + backup_prefix: { type: string } + steps: + - cf_automation_task: + auth_client_secret: << parameters.auth_client_secret >> + cloudgov_username: << parameters.cloudgov_username >> + cloudgov_password: << parameters.cloudgov_password >> + cloudgov_space: << parameters.cloudgov_space >> + task_name: "backup" + task_command: "cd /home/vcap/app/db-backup/scripts; bash ./db_backup.sh" + task_args: '["<< parameters.backup_prefix >>", "<< parameters.rds_service_name >>", "<< parameters.s3_service_name >>"]' + config: "<< parameters.backup_prefix >>-backup" + success_message: ':download::database: "<< parameters.backup_prefix >>" backup' + cf_restore: + description: "Restore backup database from S3" + parameters: + auth_client_secret: { type: env_var_name } + cloudgov_username: { type: env_var_name } + cloudgov_password: { type: env_var_name } + cloudgov_space: { type: env_var_name } + rds_service_name: { type: string } + s3_service_name: { type: string } + backup_prefix: { type: string } + steps: + - run: + name: Validate Parameters + command: | + if [ "<< parameters.rds_service_name >>" = "ttahub-prod" ]; then + echo "Error: rds_service_name cannot be 'ttahub-prod'" + exit 1 + fi + - cf_automation_task: + auth_client_secret: << parameters.auth_client_secret >> + cloudgov_username: << parameters.cloudgov_username >> + cloudgov_password: << parameters.cloudgov_password >> + cloudgov_space: << parameters.cloudgov_space >> + task_name: "restore" + task_command: "cd /home/vcap/app/db-backup/scripts; bash ./db_restore.sh" + task_args: '["<< parameters.backup_prefix >>", "<< parameters.rds_service_name >>", "<< parameters.s3_service_name >>"]' + config: "<< parameters.backup_prefix >>-restore" + success_message: ':database: "<< parameters.backup_prefix >>" Restored to "<< parameters.rds_service_name >>"' + timeout: "900" + cf_process: + description: "Process database from S3" + parameters: + auth_client_secret: { type: env_var_name } + cloudgov_username: { type: env_var_name } + cloudgov_password: { type: env_var_name } + cloudgov_space: { type: env_var_name } + steps: + - cf_automation_task: + auth_client_secret: << parameters.auth_client_secret >> + cloudgov_username: << parameters.cloudgov_username >> + cloudgov_password: << parameters.cloudgov_password >> + cloudgov_space: << parameters.cloudgov_space >> + task_name: "process" + task_command: "cd /home/vcap/app/automation/nodejs/scripts; bash ./run.sh" + task_args: '["/home/vcap/app/build/server/src/tools/processDataCLI.js"]' + config: "process" + success_message: ':database: Restored data processed' + directory: "./" + timeout: "3000" parameters: cg_org: description: "Cloud Foundry cloud.gov organization name" @@ -563,7 +665,7 @@ parameters: default: "kw-unsafe-inline" type: string sandbox_git_branch: # change to feature branch to test deployment - default: "mb/TTAHUB-3483/checkbox-to-activity-reports" + default: "TTAHUB-3613/admin-build-info2" type: string prod_new_relic_app_id: default: "877570491" @@ -580,6 +682,18 @@ parameters: manual-trigger: type: boolean default: false + manual-restore: + type: boolean + default: false + manual-process: + type: boolean + default: false + manual-backup: + type: boolean + default: false + manual-full-process: + type: boolean + default: false fail-on-modified-lines: type: boolean default: false @@ -709,6 +823,7 @@ jobs: if [ -n "${CIRCLE_PULL_REQUEST}" ]; then chmod +x ./tools/check-coverage.js node -r esm ./tools/check-coverage.js \ + --directory-filter=src/,tools/ \ --fail-on-uncovered=<< pipeline.parameters.fail-on-modified-lines >> \ --output-format=json,html else @@ -1296,10 +1411,65 @@ jobs: rds_service_name: ttahub-prod s3_service_name: ttahub-db-backups backup_prefix: production + restore_production_for_processing: + docker: + - image: cimg/base:2024.05 + steps: + - sparse_checkout: + directories: 'automation' + branch: << pipeline.git.branch >> + - cf_restore: + auth_client_secret: PROD_AUTH_CLIENT_SECRET + cloudgov_username: CLOUDGOV_PROD_USERNAME + cloudgov_password: CLOUDGOV_PROD_PASSWORD + cloudgov_space: CLOUDGOV_PROD_SPACE + rds_service_name: ttahub-process + s3_service_name: ttahub-db-backups + backup_prefix: production + process_production: + executor: docker-executor + steps: + - checkout + - create_combined_yarnlock + - restore_cache: + keys: + # To manually bust the cache, increment the version e.g. v7-yarn... + - v14-yarn-deps-{{ checksum "combined-yarnlock.txt" }} + # If checksum is new, restore partial cache + - v14-yarn-deps- + - run: yarn deps + - run: + name: Build backend assets + command: yarn build + - cf_process: + auth_client_secret: PROD_AUTH_CLIENT_SECRET + cloudgov_username: CLOUDGOV_PROD_USERNAME + cloudgov_password: CLOUDGOV_PROD_PASSWORD + cloudgov_space: CLOUDGOV_PROD_SPACE + process_backup: + docker: + - image: cimg/base:2024.05 + steps: + - sparse_checkout: + directories: 'automation' + branch: << pipeline.git.branch >> + - cf_backup: + auth_client_secret: PROD_AUTH_CLIENT_SECRET + cloudgov_username: CLOUDGOV_PROD_USERNAME + cloudgov_password: CLOUDGOV_PROD_PASSWORD + cloudgov_space: CLOUDGOV_PROD_SPACE + rds_service_name: ttahub-process + s3_service_name: ttahub-db-backups + backup_prefix: processed workflows: build_test_deploy: when: - equal: [false, << pipeline.parameters.manual-trigger >>] + and: + - equal: [false, << pipeline.parameters.manual-trigger >>] + - equal: [false, << pipeline.parameters.manual-restore >>] + - equal: [false, << pipeline.parameters.manual-process >>] + - equal: [false, << pipeline.parameters.manual-backup >>] + - equal: [false, << pipeline.parameters.manual-full-process >>] jobs: - build_and_lint - build_and_lint_similarity_api @@ -1402,8 +1572,46 @@ workflows: - << pipeline.parameters.prod_git_branch >> jobs: - backup_upload_production + - restore_production_for_processing: + requires: + - backup_upload_production + - process_production: + requires: + - restore_production_for_processing + - process_backup: + requires: + - process_production manual_backup_upload_production: when: equal: [true, << pipeline.parameters.manual-trigger >>] jobs: - backup_upload_production + manual_restore_production: + when: + equal: [true, << pipeline.parameters.manual-restore >>] + jobs: + - restore_production_for_processing + manual_process_production: + when: + equal: [true, << pipeline.parameters.manual-process >>] + jobs: + - process_production + manual_process_backup: + when: + equal: [true, << pipeline.parameters.manual-backup >>] + jobs: + - process_backup + manual_production_to_processed: + when: + equal: [true, << pipeline.parameters.manual-full-process >>] + jobs: + - backup_upload_production + - restore_production_for_processing: + requires: + - backup_upload_production + - process_production: + requires: + - restore_production_for_processing + - process_backup: + requires: + - process_production diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 02720f551d..ad4ad20a64 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,6 +1,21 @@ #!/usr/bin/env bash set -e +# Set USE_DOCKER based on whether both backend and frontend containers are running +USE_DOCKER=false +if [ "$(docker ps | grep -E "head-start-ttadp-backend|head-start-ttadp-frontend" | wc -l)" -eq 2 ]; then + USE_DOCKER=true +fi + +# Ensure node_modules is populated for frontend or backend (only for non-Docker users) +check_and_run_yarn() { + local dir=$1 + if [ ! -d "$dir/node_modules" ] || [ -z "$(ls -A "$dir/node_modules")" ]; then + echo "Installing dependencies in $dir" + (cd "$dir" && yarn) + fi +} + common_changed=false common_package_json_changed=false frontend_yarn_lock_changed=false @@ -9,7 +24,6 @@ files=$(git diff --cached --name-only) for f in $files do - # check if packages/common/src/index.js was changed if [ -e "$f" ] && [[ $f == packages/common/src/index.js ]]; then common_changed=true @@ -37,19 +51,28 @@ do git add "$f" fi - # Autolint changed .js files - if [ -e "$f" ] && [[ $f == *.js ]]; then - yarn lint:fix:single "$f" - git add "$f" - fi - - # Autolint changed .ts files - if [ -e "$f" ] && [[ $f == *.ts ]]; then - yarn lint:fix:single "$f" + # Autolint changed .js and .ts files + if [ -e "$f" ] && ([[ $f == *.js ]] || [[ $f == *.ts ]]); then + if [ "$USE_DOCKER" = true ]; then + if [[ $f == frontend/* ]]; then + yarn docker:yarn:fe lint:fix:single "$f" + else + yarn docker:yarn:be lint:fix:single "$f" + fi + else + if [[ $f == frontend/* ]]; then + check_and_run_yarn frontend + yarn lint:fix:single "$f" + else + check_and_run_yarn "." + yarn lint:fix:single "$f" + fi + fi git add "$f" fi done +# Versioning and lock file checks if [ $common_changed = true ]; then if [ $common_package_json_changed = false ]; then echo "ERROR: common/src/index.js was changed, but common/package.json was not updated. Please make sure to publish a new version." diff --git a/automation/ci/scripts/cf_lambda.sh b/automation/ci/scripts/cf_lambda.sh index 8ce82d261a..441d0bc035 100644 --- a/automation/ci/scripts/cf_lambda.sh +++ b/automation/ci/scripts/cf_lambda.sh @@ -266,7 +266,7 @@ function cleanup_service_key() { # ----------------------------------------------------------------------------- # Function to see if app already exists function check_app_exists { - local app_name=$1 + local app_name="tta-automation" # Check if an application exists by querying it local output @@ -287,7 +287,7 @@ function check_app_exists { # Function to check if an app is running function check_app_running { - local app_name=$1 + local app_name="tta-automation" # Get the application information local output @@ -295,60 +295,155 @@ function check_app_running { local status=$? if [ $status -eq 0 ]; then - if echo "$output" | grep -q "running"; then + # Extract the 'requested state' and 'instances' lines + local requested_state + requested_state=$(echo "$output" | awk -F": *" '/requested state:/ {print $2}' | xargs) + local instances_line + instances_line=$(echo "$output" | awk -F": *" '/instances:/ {print $2}' | xargs) + + # Extract the number of running instances + local running_instances=$(echo "$instances_line" | cut -d'/' -f1) + local total_instances=$(echo "$instances_line" | cut -d'/' -f2) + + if [[ "$requested_state" == "started" && "$running_instances" -ge 1 ]]; then log "INFO" "Application '$app_name' is running." - return 0 # true in Bash, application is running + return 0 # Application is running else log "INFO" "Application '$app_name' is not running." - return 1 # false in Bash, application is not running + return 1 # Application is not running fi else log "ERROR" "Failed to check if application '$app_name' is running. Error output: $output" - return $status # return the actual error code + return $status # Return the actual error code fi } +# Ensure the application is stopped +function ensure_app_stopped() { + local app_name="tta-automation" + local timeout=${1:-300} # Default timeout is 300 seconds (5 minutes) + + log "INFO" "Ensuring application '$app_name' is stopped..." + local start_time=$(date +%s) + local current_time + + while true; do + if ! check_app_running; then + log "INFO" "Application '$app_name' is already stopped." + return 0 # App is stopped + fi + + current_time=$(date +%s) + if (( current_time - start_time >= timeout )); then + log "ERROR" "Timeout reached while waiting for application '$app_name' to stop." + return 1 # Timeout reached + fi + + log "INFO" "Application '$app_name' is running. Waiting for it to stop..." + sleep 10 + done +} + +# Unbind all services from the application +function unbind_all_services() { + local app_name="tta-automation" + validate_parameters "$app_name" + + # Get the list of services bound to the application + local services + services=$(cf services | grep "$app_name" | awk '{print $1}') >&2 + + if [[ -z "$services" ]]; then + return 0 + fi + + # Loop through each service and unbind it from the application + for service in $services; do + if ! cf unbind-service "$app_name" "$service" >&2; then + log "ERROR" "Failed to unbind service $service from application $app_name." + return 1 + fi + done + + return 0 +} + # Push the app using a manifest from a specific directory function push_app { + local app_name="tta-automation" local original_dir=$(pwd) # Save the original directory local directory=$1 - local manifest_file=$2 + local config=$2 + validate_parameters "$directory" - validate_parameters "$manifest_file" + validate_parameters "$config" - # Change to the specified directory + # Change to the specified directory and find the manifest file cd "$directory" || { log "ERROR" "Failed to change directory to $directory"; cd "$original_dir"; exit 1; } + local manifest_file=$(find . -type f -name "dynamic-manifest.yml" | head -n 1) - # Extract app name from the manifest file - local app_name=$(grep 'name:' "$manifest_file" | awk '{print $3}' | tr -d '"') + if [ -z "$manifest_file" ]; then + log "ERROR" "Manifest file dynamic-manifest.yml not found in directory $directory or its subdirectories" + cd "$original_dir" + exit 1 + fi + + # Load the environment from the config file relative to the manifest directory + local config_file="$(dirname "$manifest_file")/configs/${config}.yml" - # Push the app without routing or starting it, capturing output - local push_output - if ! push_output=$(cf push -f "$manifest_file" --no-route --no-start 2>&1); then - log "ERROR" "Failed to push application with error: $push_output" - cd "$original_dir" # Restore the original directory + if [ ! -f "$config_file" ]; then + log "ERROR" "Config file $config_file not found" + cd "$original_dir" + exit 1 + fi + + # Unbind services and push the app + unbind_all_services + + # Scale down all processes to zero + for process in $(cf app $app_name --guid | jq -r '.process_types[]'); do + if ! cf scale $app_name -i 0 -p "$process" 2>&1; then + log "ERROR" "Failed to scale down process: $process" + cd "$original_dir" + exit 1 + else + log "INFO" "Scaled down process: $process." + fi + done + + # Delete all processes + for process in $(cf app $app_name --guid | jq -r '.process_types[]'); do + if ! cf delete-process $app_name "$process" -f 2>&1; then + log "ERROR" "Failed to delete process: $process" + cd "$original_dir" + exit 1 + else + log "INFO" "Deleted process: $process." + fi + done + + # Push the app + if ! cf push -f "$manifest_file" --vars-file "$config_file" --no-route --no-start 2>&1; then + log "ERROR" "Failed to push application" + + cd "$original_dir" exit 1 else log "INFO" "Application pushed successfully." fi - # Restore the original directory + # Restore original directory cd "$original_dir" - - # Log and return the app name - log "INFO" "The app name is: $app_name" - echo $app_name } - # Function to start an app function start_app { - local app_name=$1 - validate_parameters "$app_name" + local app_name="tta-automation" log "INFO" "Starting application '$app_name'..." if ! cf start "$app_name"; then log "ERROR" "Failed to start application '$app_name'." + stop_app exit 1 else log "INFO" "Application '$app_name' started successfully." @@ -357,8 +452,10 @@ function start_app { # Function to stop an app function stop_app { - local app_name=$1 - validate_parameters "$app_name" + local app_name="tta-automation" + + # Unbind all services after stopping the app + unbind_all_services log "INFO" "Stopping application '$app_name'..." if ! cf stop "$app_name"; then @@ -371,8 +468,8 @@ function stop_app { # Function to manage the state of the application (start, restage, stop) function manage_app { - local app_name=$1 - local action=$2 # Action can be 'start', 'stop', or 'restage' + local app_name="tta-automation" + local action=$1 # Action can be 'start', 'stop', or 'restage' # Validate the action parameter if [[ "$action" != "start" && "$action" != "stop" && "$action" != "restage" ]]; then @@ -380,6 +477,7 @@ function manage_app { return 1 # Exit with an error status fi + log "INFO" "Telling application '$app_name' to $action..." # Perform the action on the application local output output=$(cf "$action" "$app_name" 2>&1) @@ -396,23 +494,43 @@ function manage_app { # Function to run a task with arguments function run_task { - local app_name=$1 - local task_name=$2 - local command=$3 - local args_json=$4 + local app_name="tta-automation" + local task_name=$1 + local command=$2 + local args_json=$3 + local config=$4 # New parameter for config - validate_parameters "$app_name" validate_parameters "$command" validate_parameters "$task_name" validate_parameters "$args_json" + validate_parameters "$config" + + # Load the environment from the config file relative to the manifest directory + local config_file="./automation/configs/${config}.yml" + + if [ ! -f "$config_file" ]; then + log "ERROR" "Config file $config_file not found" + exit 1 + fi + + # Extract memory value from config file using awk + local memory + memory=$(awk '/memory:/ {print $2}' "$config_file") + + if [ -z "$memory" ]; then + log "ERROR" "Memory value not found in config file $config_file" + exit 1 + fi + + # Convert memory from GB to G if necessary + memory=$(echo "$memory" | sed 's/GB/G/') # Convert JSON array to space-separated list of arguments local args=$(echo "$args_json" | jq -r '.[]' | sed 's/\(.*\)/"\1"/' | tr '\n' ' ' | sed 's/ $/\n/') - - log "INFO" "Running task: $task_name with args: $args" + log "INFO" "Running task: $task_name with args: $args and memory: $memory" local full_command="$command $args" - cf run-task "$app_name" --command "$full_command" --name "$task_name" + cf run-task "$app_name" --command "$full_command" --name "$task_name" -m "$memory" local result=$? if [ $result -ne 0 ]; then log "ERROR" "Failed to start task $task_name with error code $result" @@ -420,14 +538,12 @@ function run_task { fi } - - # Function to monitor task function monitor_task { - local app_name=$1 - local task_name=$2 - local timeout=${3:-300} # Default timeout in seconds - validate_parameters "$app_name" + local app_name="tta-automation" + local task_name=$1 + local timeout=${2:-300} # Default timeout in seconds + validate_parameters "$task_name" local start_time local task_id @@ -455,10 +571,39 @@ function monitor_task { done } +# Check for active tasks in the application +function check_active_tasks() { + local app_name="tta-automation" + local timeout=${1:-300} # Default timeout is 300 seconds (5 minutes) + + log "INFO" "Checking for active tasks in application '$app_name'..." + local start_time=$(date +%s) + local current_time + local active_tasks + + while true; do + active_tasks=$(cf tasks "$app_name" | grep -E "RUNNING|PENDING") + + if [ -z "$active_tasks" ]; then + log "INFO" "No active tasks found in application '$app_name'." + return 0 # No active tasks + fi + + current_time=$(date +%s) + if (( current_time - start_time >= timeout )); then + log "ERROR" "Timeout reached while waiting for active tasks to complete in application '$app_name'." + return 1 # Timeout reached + fi + + log "INFO" "Active tasks found. Waiting for tasks to complete..." + sleep 10 + done +} + # Function to delete the app function delete_app { - local app_name=$1 - validate_parameters "$app_name" + local app_name="tta-automation" + # Attempt to delete the application with options to force deletion without confirmation # and to recursively delete associated routes and services. cf delete "$app_name" -f -r @@ -482,30 +627,45 @@ main() { validate_json "$json_input" # Parse JSON and assign to variables - local automation_dir manifest task_name command args - automation_dir=$(echo "$json_input" | jq -r '.automation_dir // "./automation"') - manifest=$(echo "$json_input" | jq -r '.manifest // "manifest.yml"') + local directory config task_name command args timeout_active_tasks timeout_ensure_app_stopped + directory=$(echo "$json_input" | jq -r '.directory // "./automation"') + config=$(echo "$json_input" | jq -r '.config // "error"') task_name=$(echo "$json_input" | jq -r '.task_name // "default-task-name"') command=$(echo "$json_input" | jq -r '.command // "bash /path/to/default-script.sh"') args=$(echo "$json_input" | jq -r '.args // "default-arg1 default-arg2"') + timeout_active_tasks=$(echo "$json_input" | jq -r '.timeout_active_tasks // 300') + timeout_ensure_app_stopped=$(echo "$json_input" | jq -r '.timeout_ensure_app_stopped // 300') - local service_credentials + # Check for active tasks and ensure the app is stopped before pushing + if check_app_exists; then + if ! check_active_tasks "$timeout_active_tasks"; then + log "ERROR" "Cannot proceed with pushing the app due to active tasks." + exit 1 + fi + if ! ensure_app_stopped "$timeout_ensure_app_stopped"; then + log "ERROR" "Cannot proceed with pushing the app as it is still running." + exit 1 + fi + fi - app_name=$(push_app "$automation_dir" "$manifest") - start_app "$app_name" + # Push the app without returning memory + push_app "$directory" "$config" + start_app - if run_task "$app_name" "$task_name" "$command" "$args" && monitor_task "$app_name" "$task_name"; then + # Pass the config to run_task instead of memory + if run_task "$task_name" "$command" "$args" "$config" && monitor_task "$task_name" $timeout_active_tasks; then log "INFO" "Task execution succeeded." else log "ERROR" "Task execution failed." - stop_app "$app_name" + stop_app exit 1 fi # Clean up - stop_app "$app_name" - # Currently only turing off to aid in speeding up cycle time - # delete_app "$app_name" + stop_app + # Currently only turning off to aid in speeding up cycle time + # delete_app "tta-automation" } + main "$@" diff --git a/automation/common/scripts/postgrescli_install.sh b/automation/common/scripts/postgrescli_install.sh index a056c01097..5851a46b40 100644 --- a/automation/common/scripts/postgrescli_install.sh +++ b/automation/common/scripts/postgrescli_install.sh @@ -221,9 +221,9 @@ function cleanup() { # Main function to control workflow function main() { - local deb_url="https://security.debian.org/debian-security/pool/updates/main/p/postgresql-15/postgresql-client-15_15.8-0+deb12u1_amd64.deb" + local deb_url="https://security.debian.org/debian-security/pool/updates/main/p/postgresql-15/postgresql-client-15_15.10-0+deb12u1_amd64.deb" local deb_file="/tmp/postgresql.deb" - local deb_sha256="e88cfe7aa8548f8461dcbd56f69a1bb365affcd380469f705aca697fc2146994" + local deb_sha256="cb193447c404d85eed93cb0f61d2f189dd1c94c3f1af4d74afe861e06f8b34db" local bin_dir="/tmp/local/bin" local tools=("pg_dump" "pg_isready" "pg_restore" "psql" "reindexdb" "vacuumdb") diff --git a/automation/configs/process.yml b/automation/configs/process.yml new file mode 100644 index 0000000000..4732c4aae5 --- /dev/null +++ b/automation/configs/process.yml @@ -0,0 +1,9 @@ +instances: 1 +memory: 512M +disk_quota: 1G + +buildpack: "https://github.com/cloudfoundry/nodejs-buildpack" +command: "./automation/cf/scripts/idol.sh" + +bound_services: + - ttahub-process diff --git a/automation/configs/processed-backup.yml b/automation/configs/processed-backup.yml new file mode 100644 index 0000000000..de17eebd81 --- /dev/null +++ b/automation/configs/processed-backup.yml @@ -0,0 +1,10 @@ +instances: 1 +memory: 512M +disk_quota: 64M + +buildpack: "binary_buildpack" +command: "./cf/scripts/idol.sh" + +bound_services: + - ttahub-process + - ttahub-db-backups diff --git a/automation/configs/processed-restore.yml b/automation/configs/processed-restore.yml new file mode 100644 index 0000000000..1dd649c915 --- /dev/null +++ b/automation/configs/processed-restore.yml @@ -0,0 +1,12 @@ +instances: 1 +memory: 512M +disk_quota: 64M + +buildpack: "binary_buildpack" +command: "./cf/scripts/idol.sh" + +bound_services: + - ttahub-db-backups + - ttahub-dev + - ttahub-sandbox + - ttahub-staging diff --git a/automation/configs/production-backup.yml b/automation/configs/production-backup.yml new file mode 100644 index 0000000000..915aaeb802 --- /dev/null +++ b/automation/configs/production-backup.yml @@ -0,0 +1,10 @@ +instances: 1 +memory: 512M +disk_quota: 64M + +buildpack: "binary_buildpack" +command: "./cf/scripts/idol.sh" + +bound_services: + - ttahub-prod + - ttahub-db-backups diff --git a/automation/configs/production-restore.yml b/automation/configs/production-restore.yml new file mode 100644 index 0000000000..f102c2572a --- /dev/null +++ b/automation/configs/production-restore.yml @@ -0,0 +1,10 @@ +instances: 1 +memory: 512M +disk_quota: 64M + +buildpack: "binary_buildpack" +command: "./cf/scripts/idol.sh" + +bound_services: + - ttahub-db-backups + - ttahub-process diff --git a/automation/db-backup/scripts/db_backup.sh b/automation/db-backup/scripts/db_backup.sh index 0c0f4421ec..14872903d1 100644 --- a/automation/db-backup/scripts/db_backup.sh +++ b/automation/db-backup/scripts/db_backup.sh @@ -342,12 +342,6 @@ function rds_test_connectivity() { fi } -function rds_dump_prep() { - rds_validate - - # all arguments are read from exports directly - echo "pg_dump" -} # ----------------------------------------------------------------------------- # ----------------------------------------------------------------------------- @@ -477,7 +471,7 @@ function aws_s3_safe_remove_file() { } aws_s3_verify_file_integrity() { - local zip_file_path="$1" + local backup_file_path="$1" local md5_file_path="$2" local sha256_file_path="$3" @@ -489,7 +483,7 @@ aws_s3_verify_file_integrity() { log "INFO" "Prepare the command to stream the S3 file and calculate hashes" set +e log "INFO" "Execute the command and capture its exit status" - aws s3 cp "s3://${zip_file_path}" - |\ + aws s3 cp "s3://${backup_file_path}" - |\ tee \ >(sha256sum |\ awk '{print $1}' > /tmp/computed_sha256 &\ @@ -519,7 +513,6 @@ aws_s3_verify_file_integrity() { local computed_md5 computed_sha256 read computed_md5 < /tmp/computed_md5 read computed_sha256 < /tmp/computed_sha256 - rm -f /tmp/computed_md5 /tmp/computed_sha256 log "INFO" "Verify hashes" if [ "$computed_md5" != "$expected_md5" ] || [ "$computed_sha256" != "$expected_sha256" ]; then @@ -531,6 +524,7 @@ aws_s3_verify_file_integrity() { fi log "INFO" "File hashes verified" + rm -f /tmp/computed_md5 /tmp/computed_sha256 set -e return 0 } @@ -539,14 +533,6 @@ aws_s3_verify_file_integrity() { # ----------------------------------------------------------------------------- # Backup & Upload helper functions # ----------------------------------------------------------------------------- -zip_prep() { - local zip_password=$1 - - parameters_validate "${zip_password}" - - echo "zip -P \"${zip_password}\" - -" -} - perform_backup_and_upload() { local backup_filename_prefix=$1 @@ -554,27 +540,19 @@ perform_backup_and_upload() { local s3_bucket=$AWS_DEFAULT_BUCKET - local zip_password timestamp - zip_password=$(openssl rand -base64 12) + local backup_password timestamp + backup_password=$(openssl rand -base64 12) timestamp="$(date --utc +%Y-%m-%d-%H-%M-%S)-UTC" - local zip_filename="${backup_filename_prefix}-${timestamp}.sql.zip" + local backup_filename="${backup_filename_prefix}-${timestamp}.sql.zenc" local md5_filename="${backup_filename_prefix}-${timestamp}.sql.md5" local sha256_filename="${backup_filename_prefix}-${timestamp}.sql.sha256" local password_filename="${backup_filename_prefix}-${timestamp}.sql.pwd" local latest_backup_filename="${backup_filename_prefix}-latest-backup.txt" - local rds_dump_cmd - rds_dump_cmd=$(rds_dump_prep) - parameters_validate "${rds_dump_cmd}" - - local zip_cmd - zip_cmd=$(zip_prep "${zip_password}") - parameters_validate "${zip_cmd}" - - local aws_s3_copy_zip_file_cmd - aws_s3_copy_zip_file_cmd=$(aws_s3_copy_file_prep "$zip_filename" "${backup_filename_prefix}") - parameters_validate "${aws_s3_copy_zip_file_cmd}" + local aws_s3_copy_backup_file_cmd + aws_s3_copy_backup_file_cmd=$(aws_s3_copy_file_prep "$backup_filename" "${backup_filename_prefix}") + parameters_validate "${aws_s3_copy_backup_file_cmd}" local aws_s3_copy_md5_file_cmd aws_s3_copy_md5_file_cmd=$(aws_s3_copy_file_prep "$md5_filename" "${backup_filename_prefix}") @@ -595,7 +573,8 @@ perform_backup_and_upload() { log "INFO" "Execute the command and capture its exit status" set +e pg_dump |\ - zip -P "${zip_password}" - - |\ + gzip |\ + openssl enc -aes-256-cbc -salt -pbkdf2 -k "${backup_password}" |\ tee \ >(md5sum |\ awk '{print $1}' |\ @@ -607,7 +586,7 @@ perform_backup_and_upload() { aws s3 cp - "s3://${s3_bucket}/${backup_filename_prefix}/${sha256_filename}" ;\ echo $? > /tmp/sha256_status \ ) |\ - aws s3 cp - "s3://${s3_bucket}/${backup_filename_prefix}/${zip_filename}" + aws s3 cp - "s3://${s3_bucket}/${backup_filename_prefix}/${backup_filename}" local main_exit_status=$? log "INFO" "Wait for all subprocesses and check their exit statuses" @@ -623,34 +602,34 @@ perform_backup_and_upload() { log "INFO" "Check if any of the backup uploads or integrity checks failed" if [ "$md5_exit_status" -ne 0 ] || [ "$sha256_exit_status" -ne 0 ] || [ "$main_exit_status" -ne 0 ]; then log "ERROR" "Backup upload failed." - aws_s3_safe_remove_file "${zip_filename}" + aws_s3_safe_remove_file "${backup_filename}" aws_s3_safe_remove_file "${md5_filename}" aws_s3_safe_remove_file "${sha256_filename}" set -e return 1 fi - log "INFO" "Upload the ZIP password" - if ! echo -n "${zip_password}" |\ + log "INFO" "Upload the backup password" + if ! echo -n "${backup_password}" |\ eval "$aws_s3_copy_password_file_cmd"; then log "ERROR" "Password file upload failed." aws_s3_safe_remove_file "${password_filename}" - aws_s3_safe_remove_file "${zip_filename}" + aws_s3_safe_remove_file "${backup_filename}" aws_s3_safe_remove_file "${md5_filename}" aws_s3_safe_remove_file "${sha256_filename}" set -e return 1 fi - local zip_file_path="${s3_bucket}/${backup_filename_prefix}/${zip_filename}" + local backup_file_path="${s3_bucket}/${backup_filename_prefix}/${backup_filename}" local md5_file_path="${s3_bucket}/${backup_filename_prefix}/${md5_filename}" local sha256_file_path="${s3_bucket}/${backup_filename_prefix}/${sha256_filename}" local password_file_path="${s3_bucket}/${backup_filename_prefix}/${password_filename}" - if ! aws_s3_verify_file_integrity "${zip_file_path}" "${md5_file_path}" "${sha256_file_path}"; then + if ! aws_s3_verify_file_integrity "${backup_file_path}" "${md5_file_path}" "${sha256_file_path}"; then log "ERROR" "Verification of file integrity check failed" aws_s3_safe_remove_file "${password_filename}" - aws_s3_safe_remove_file "${zip_filename}" + aws_s3_safe_remove_file "${backup_filename}" aws_s3_safe_remove_file "${md5_filename}" aws_s3_safe_remove_file "${sha256_filename}" set -e @@ -658,7 +637,7 @@ perform_backup_and_upload() { fi log "INFO" "Update the latest backup file list" - if ! printf "%s\n%s\n%s\n%s" "${zip_file_path}" "${md5_file_path}" "${sha256_file_path}" "${password_file_path}" |\ + if ! printf "%s\n%s\n%s\n%s" "${backup_file_path}" "${md5_file_path}" "${sha256_file_path}" "${password_file_path}" |\ eval "${aws_s3_copy_latest_backup_file_cmd}"; then log "ERROR" "Latest backup file list upload failed." set -e @@ -712,7 +691,7 @@ backup_retention() { delete_backup_set() { BASE_NAME=$1 - for EXT in ".zip" ".pwd" ".md5" ".sha256"; do + for EXT in ".zenc" ".pwd" ".md5" ".sha256"; do KEY="${BASE_NAME}${EXT}" log "INFO" "Deleting $KEY" aws s3 rm "s3://${s3_bucket}/${KEY}" || { @@ -793,7 +772,7 @@ function main() { set -e exit 1 } - + log "INFO" "Verify or install postgrescli" run_script 'postgrescli_install.sh' '../../common/scripts/' || { log "ERROR" "Failed to install or verify postgrescli" @@ -805,7 +784,7 @@ function main() { add_to_path '/tmp/local/bin' log "INFO" "check dependencies" - check_dependencies aws md5sum openssl pg_dump pg_isready sha256sum zip + check_dependencies aws md5sum openssl pg_dump pg_isready sha256sum gzip log "INFO" "collect and configure credentials" rds_prep "${VCAP_SERVICES}" "${rds_server}" || { @@ -813,7 +792,7 @@ function main() { set -e exit 1 } - + aws_s3_prep "${VCAP_SERVICES}" "${aws_s3_server}" || { log "ERROR" "Failed to prepare AWS S3 credentials" set -e @@ -826,7 +805,7 @@ function main() { set -e exit 1 } - + s3_test_connectivity || { log "ERROR" "S3 connectivity test failed" set -e diff --git a/automation/db-backup/scripts/db_restore.sh b/automation/db-backup/scripts/db_restore.sh new file mode 100644 index 0000000000..e5b4101f1d --- /dev/null +++ b/automation/db-backup/scripts/db_restore.sh @@ -0,0 +1,640 @@ +#!/bin/bash +set -e +set -u +set -o pipefail +set -o noglob +set -o noclobber + +# ----------------------------------------------------------------------------- +# Generic helper functions +# ----------------------------------------------------------------------------- +# Enhanced logging function with timestamp and output stream handling +function log() { + local type="$1" + local message="$2" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$timestamp] $type: $message" >&2 +} + +# Parameter Validation +function parameters_validate() { + local param="$1" + if [[ -z "${param}" ]]; then + log "ERROR" "Parameter is unset or empty." + set -e + exit 1 + fi +} + +# Export Validation +function export_validate() { + local param="$1" + + # Check if the parameter is set + if ! declare -p "$param" &>/dev/null; then + log "ERROR" "Parameter '$param' is unset." + set -e + exit 1 + fi + + # Check if the parameter is exported + if [[ "$(declare -p "$param")" != *" -x "* ]]; then + log "ERROR" "Parameter '$param' is not exported." + set -e + exit 1 + fi +} + +# Check for required dependencies +function check_dependencies() { + local dependencies=("$@") + for dep in "${dependencies[@]}"; do + if ! type "${dep}" > /dev/null 2>&1; then + log "ERROR" "Dependency ${dep} is not installed." + set -e + exit 1 + fi + done +} + +# Add a directory to PATH if it is not already included +function add_to_path() { + local new_dir="$1" + + if [[ ":$PATH:" != *":$new_dir:"* ]]; then + export PATH="$new_dir:$PATH" + log "INFO" "Added $new_dir to PATH." + else + log "INFO" "$new_dir is already in PATH." + fi +} + +# monitor memory usage +function monitor_memory() { + local pid=$1 + local interval=${2-0.5} + local max_mem_mb=0 + local max_system_mem_mb=0 + local mem_kb + local mem_mb + local system_mem_bytes + local system_mem_mb + local start_time + start_time=$(date +%s) # Record start time in seconds + + # Path to the container's memory cgroup + local MEM_CGROUP_PATH="/sys/fs/cgroup/memory" + + # Trap to handle script exits and interruptions + local exit_code duration end_time + trap 'exit_code=$?; \ + end_time=$(date +%s); \ + duration=$((end_time - start_time)); \ + log "STAT" "Exit code: $exit_code"; \ + log "STAT" "Maximum memory used by the process: $max_mem_mb MB"; \ + log "STAT" "Maximum container memory used: $max_system_mem_mb MB"; \ + log "STAT" "Duration of the run: $duration seconds from $start_time to $end_time"; \ + exit $exit_code' EXIT SIGINT SIGTERM + + # Monitor memory usage + log "INFO" "Monitoring started at: $start_time"; + while true; do + if [ ! -e "/proc/$pid" ]; then + break + fi + # Process-specific memory in kilobytes, then convert to megabytes + mem_kb=$(awk '/VmRSS/{print $2}' "/proc/$pid/status" 2>/dev/null) + mem_mb=$((mem_kb / 1024)) + if [[ "$mem_mb" -gt "$max_mem_mb" ]]; then + max_mem_mb=$mem_mb + fi + + # Container-specific memory (used memory) in bytes, then convert to megabytes + system_mem_bytes=$(cat $MEM_CGROUP_PATH/memory.usage_in_bytes) + system_mem_mb=$((system_mem_bytes / 1024 / 1024)) + if [[ "$system_mem_mb" -gt "$max_system_mem_mb" ]]; then + max_system_mem_mb=$system_mem_mb + fi + + sleep "$interval" + done +} +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# JSON helper functions +# ----------------------------------------------------------------------------- +# Validate JSON +function validate_json() { + local json_data="$1" + log "INFO" "Validating JSON..." + if ! echo "${json_data}" | jq empty 2>/dev/null; then + log "ERROR" "Invalid JSON format." + set -e + exit 6 + fi +} + +# Append to a JSON array +function append_to_json_array() { + local existing_json="$1" + local new_json="$2" + + validate_json "$existing_json" + validate_json "$new_json" + + # Use jq to append the new JSON object to the existing array + updated_json=$(jq --argjson obj "$new_json" '. += [$obj]' <<< "$existing_json") + + # Check if the update was successful + if ! updated_json=$(jq --argjson obj "$new_json" '. += [$obj]' <<< "$existing_json"); then + log "ERROR" "Failed to append JSON object." + set -e + return 1 + fi + + validate_json "$updated_json" + + echo "$updated_json" +} + +# Find object in array by key & value +function find_json_object() { + local json_data="$1" + local key="$2" + local value="$3" + + validate_json "$json_data" + + # Search for the object in the JSON array + local found_object + found_object=$(jq -c --arg key "$key" --arg value "$value" '.[] | select(.[$key] == $value)' <<< "$json_data") + + # Check if an object was found + if [ -z "$found_object" ]; then + log "INFO" "No object found with $key = $value." + set -e + return 1 + else + log "INFO" "Object found" + fi + + echo "$found_object" +} + +# Function to process JSON with a jq query and handle jq errors +process_json() { + local json_string="$1" + local jq_query="$2" + local jq_flag="${3-}" + + # Use jq to process the JSON string with the provided jq query + # Capture stderr in a variable to handle jq errors + local result + result=$(echo "$json_string" | jq $jq_flag "$jq_query" 2>&1) + local jq_exit_status=$? + + # Check jq execution status + if [ $jq_exit_status -ne 0 ]; then + log "ERROR" "jq execution failed: $result" + set -e + return $jq_exit_status # Return with an error status + fi + + # Check if the result is empty or null (jq returns 'null' if no data matches the query) + if [[ -z $result || $result == "null" ]]; then + log "ERROR" "No value found for the provided jq query." + set -e + return 1 # Return with an error status + else + echo "$result" + set -e + return 0 + fi +} +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# File & Script helper functions +# ----------------------------------------------------------------------------- +# run an script and return its output if successful +run_script() { + local script_name="$1" + local script_dir="$2" + shift 2 # Shift the first two arguments out, leaving any additional arguments + + parameters_validate "${script_name}" + + log "INFO" "Resolve the full path of the script" + local script_path + if [[ -d "$script_dir" ]]; then + script_path="$(cd "$script_dir" && pwd)/$script_name" + else + log "ERROR" "The specified directory $script_dir does not exist." + set -e + return 1 # Return with an error status + fi + + log "INFO" "Check if the script exists" + if [ ! -f "$script_path" ]; then + log "ERROR" "The script $script_name does not exist at $script_path." + set -e + return 1 # Return with an error status + fi + + log "INFO" "Check if the script is executable" + if [ ! -x "$script_path" ]; then + log "ERROR" "The script $script_name is not executable." + set -e + return 1 # Return with an error status + fi + + log "INFO" "Execute the script with any passed arguments and capture its output" + script_output=$("$script_path" "$@") + local script_exit_status=$? + + log "INFO" "Check the exit status of the script" + if [ $script_exit_status -ne 0 ]; then + log "ERROR" "Script execution failed with exit status $script_exit_status. Output: $script_output" + set -e + return $script_exit_status + else + echo "$script_output" + set -e + return 0 + fi +} + +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# Postgres helper functions +# ----------------------------------------------------------------------------- +function rds_validate() { + export_validate "PGHOST" + export_validate "PGPORT" + export_validate "PGUSER" + export_validate "PGPASSWORD" + export_validate "PGDATABASE" +} + +function rds_prep() { + local json_blob=$1 + local db_server=$2 + + log "INFO" "Preparing RDS configurations." + parameters_validate "${json_blob}" + parameters_validate "${db_server}" + + log "INFO" "Extracting RDS data from provided JSON." + local rds_data + rds_data=$(process_json "${json_blob}" '."aws-rds"') + parameters_validate "${rds_data}" + local server_data + server_data=$(find_json_object "${rds_data}" "name" "${db_server}") + parameters_validate "${server_data}" + local db_host + db_host=$(process_json "${server_data}" ".credentials.host" "-r") + parameters_validate "${db_host}" + local db_port + db_port=$(process_json "${server_data}" ".credentials.port" "-r") + parameters_validate "${db_port}" + local db_username + db_username=$(process_json "${server_data}" ".credentials.username" "-r") + parameters_validate "${db_username}" + local db_password + db_password=$(process_json "${server_data}" ".credentials.password" "-r") + parameters_validate "${db_password}" + local db_name + db_name=$(process_json "${server_data}" ".credentials.name" "-r") + parameters_validate "${db_name}" + + log "INFO" "Configuring PostgreSQL client environment." + export PGHOST="${db_host}" + export PGPORT="${db_port}" + export PGUSER="${db_username}" + export PGPASSWORD="${db_password}" + export PGDATABASE="${db_name}" + + rds_validate +} + +function rds_clear() { + unset PGHOST + unset PGPORT + unset PGUSER + unset PGPASSWORD + unset PGDATABASE +} + +function rds_test_connectivity() { + rds_validate + + log "INFO" "Testing RDS connectivity using pg_isready..." + + if pg_isready > /dev/null 2>&1; then + log "INFO" "RDS database is ready and accepting connections." + else + log "ERROR" "Failed to connect to RDS database. Check server status, credentials, and network settings." + set -e + return 1 + fi +} + +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# AWS S3 helper functions +# ----------------------------------------------------------------------------- +function aws_s3_validate() { + export_validate "AWS_ACCESS_KEY_ID" + export_validate "AWS_SECRET_ACCESS_KEY" + export_validate "AWS_DEFAULT_BUCKET" + export_validate "AWS_DEFAULT_REGION" +} + +function aws_s3_prep() { + local json_blob=$1 + local s3_server=$2 + + log "INFO" "Preparing AWS S3 configurations using input parameters." + parameters_validate "${json_blob}" + parameters_validate "${s3_server}" + + log "INFO" "Processing JSON data for S3 configuration." + local s3_data + s3_data=$(process_json "${json_blob}" '."s3"') + parameters_validate "${s3_data}" + local server_data + server_data=$(find_json_object "${s3_data}" "name" "${s3_server}") + parameters_validate "${server_data}" + local s3_access_key_id + s3_access_key_id=$(process_json "${server_data}" ".credentials.access_key_id" "-r") + parameters_validate "${s3_access_key_id}" + local s3_secret_access_key + s3_secret_access_key=$(process_json "${server_data}" ".credentials.secret_access_key" "-r") + parameters_validate "${s3_secret_access_key}" + local s3_bucket + s3_bucket=$(process_json "${server_data}" ".credentials.bucket" "-r") + parameters_validate "${s3_bucket}" + local s3_region + s3_region=$(process_json "${server_data}" ".credentials.region" "-r") + parameters_validate "${s3_region}" + + log "INFO" "Setting AWS CLI environment variables." + export AWS_ACCESS_KEY_ID="${s3_access_key_id}" + export AWS_SECRET_ACCESS_KEY="${s3_secret_access_key}" + export AWS_DEFAULT_BUCKET="${s3_bucket}" + export AWS_DEFAULT_REGION="${s3_region}" + + aws_s3_validate +} + +function aws_s3_clear() { + unset AWS_ACCESS_KEY_ID + unset AWS_SECRET_ACCESS_KEY + unset AWS_DEFAULT_BUCKET + unset AWS_DEFAULT_REGION +} + +function s3_test_connectivity() { + aws_s3_validate + + log "INFO" "Testing AWS S3 connectivity..." + + if aws s3 ls "s3://$AWS_DEFAULT_BUCKET" > /dev/null 2>&1; then + log "INFO" "Successfully connected to AWS S3." + else + log "ERROR" "Failed to connect to AWS S3. Check credentials and network settings." + set -e + return 1 + fi +} + +# Download the latest backup file list +function aws_s3_get_latest_backup() { + local backup_filename_prefix=$1 + local latest_backup_filename="${backup_filename_prefix}-latest-backup.txt" + + log "INFO" "Downloading latest backup file list from S3..." + if aws s3 cp "s3://${AWS_DEFAULT_BUCKET}/${backup_filename_prefix}/${latest_backup_filename}" - > latest_backup.txt; then + log "INFO" "Successfully downloaded latest backup file list." + + # Check if the file exists and is not empty + if [ -f latest_backup.txt ]; then + if [ -s latest_backup.txt ]; then + log "INFO" "Latest backup file list exists and is not empty." + else + log "ERROR" "Downloaded latest backup file list is empty." + set -e + return 1 + fi + else + log "ERROR" "Downloaded latest backup file list does not exist." + set -e + return 1 + fi + else + log "ERROR" "Failed to download latest backup file list." + set -e + return 1 + fi +} + + +# Function to download the backup password from S3 +function aws_s3_download_password() { + local password_file_path=$1 + + log "INFO" "Downloading backup password from S3..." + local password + password=$(aws s3 cp "s3://${password_file_path}" -) + parameters_validate "${password}" + + echo "${password}" +} + +# Verify the integrity of the file downloaded from S3 +function aws_s3_verify_file_integrity() { + local backup_file_path="$1" + local md5_file_path="$2" + local sha256_file_path="$3" + + log "INFO" "Stream the expected hashes directly from S3" + local expected_md5 expected_sha256 + expected_md5=$(aws s3 cp "s3://${md5_file_path}" -) + expected_sha256=$(aws s3 cp "s3://${sha256_file_path}" -) + + log "INFO" "Prepare the command to stream the S3 file and calculate hashes" + set +e + log "INFO" "Execute the command and capture its exit status" + aws s3 cp "s3://${backup_file_path}" - |\ + tee \ + >(sha256sum |\ + awk '{print $1}' > /tmp/computed_sha256 &\ + echo $? > /tmp/sha256_status \ + ) \ + >(md5sum |\ + awk '{print $1}' > /tmp/computed_md5 &\ + echo $? > /tmp/md5_status \ + ) \ + >/dev/null + local main_exit_status=$? + + log "INFO" "Wait for all subprocesses and check their exit statuses" + local md5_exit_status sha256_exit_status + read md5_exit_status < /tmp/md5_status + read sha256_exit_status < /tmp/sha256_status + rm -f /tmp/md5_status /tmp/sha256_status + + log "INFO" "Check if any of the hash calculations failed" + if [ "$md5_exit_status" -ne 0 ] || [ "$sha256_exit_status" -ne 0 ] || [ "$main_exit_status" -ne 0 ]; then + log "ERROR" "Error during file verification." + set -e + return 1 + fi + + log "INFO" "Read computed hash values from temporary storage" + local computed_md5 computed_sha256 + read computed_md5 < /tmp/computed_md5 + read computed_sha256 < /tmp/computed_sha256 + rm -f /tmp/computed_md5 /tmp/computed_sha256 + + log "INFO" "Verify hashes" + if [ "$computed_md5" != "$expected_md5" ] || [ "$computed_sha256" != "$expected_sha256" ]; then + log "ERROR" "File verification failed." + log "ERROR" "Expected MD5: $expected_md5, Computed MD5: $computed_md5" + log "ERROR" "Expected SHA256: $expected_sha256, Computed SHA256: $computed_sha256" + set -e + return 1 + fi + + log "INFO" "File hashes verified" + set -e + return 0 +} + +# ----------------------------------------------------------------------------- +# Main restore function +# ----------------------------------------------------------------------------- +function perform_restore() { + local backup_filename_prefix=$1 + local rds_server=$2 + local aws_s3_server=$3 + + log "INFO" "Validate parameters and exports" + parameters_validate "${backup_filename_prefix}" + parameters_validate "${rds_server}" + parameters_validate "${aws_s3_server}" + + export_validate "VCAP_SERVICES" + + log "INFO" "Verify or install awscli" + run_script 'awscli_install.sh' '../../common/scripts/' || { + log "ERROR" "Failed to install or verify awscli" + set -e + exit 1 + } + + log "INFO" "Verify or install postgrescli" + run_script 'postgrescli_install.sh' '../../common/scripts/' || { + log "ERROR" "Failed to install or verify postgrescli" + set -e + exit 1 + } + + log "INFO" "add the bin dir for the new cli tools to PATH" + add_to_path '/tmp/local/bin' + + log "INFO" "check dependencies" + check_dependencies aws md5sum pg_restore sha256sum gzip openssl + + log "INFO" "collect and configure credentials" + rds_prep "${VCAP_SERVICES}" "${rds_server}" || { + log "ERROR" "Failed to prepare RDS credentials" + set -e + exit 1 + } + + aws_s3_prep "${VCAP_SERVICES}" "${aws_s3_server}" || { + log "ERROR" "Failed to prepare AWS S3 credentials" + set -e + exit 1 + } + + log "INFO" "verify rds & s3 connectivity" + rds_test_connectivity || { + log "ERROR" "RDS connectivity test failed" + set -e + exit 1 + } + + s3_test_connectivity || { + log "ERROR" "S3 connectivity test failed" + set -e + exit 1 + } + + log "INFO" "Downloading latest backup file list" + aws_s3_get_latest_backup "${backup_filename_prefix}" || { + log "ERROR" "Failed to download latest backup file list" + set -e + exit 1 + } + + log "INFO" "Reading backup file paths from the latest backup file list" + local backup_file_path md5_file_path sha256_file_path password_file_path + backup_file_path=$(awk 'NR==1' latest_backup.txt) + md5_file_path="${backup_file_path%.zenc}.md5" + sha256_file_path="${backup_file_path%.zenc}.sha256" + password_file_path="${backup_file_path%.zenc}.pwd" + parameters_validate "${backup_file_path}" + parameters_validate "${md5_file_path}" + parameters_validate "${sha256_file_path}" + parameters_validate "${password_file_path}" + + log "INFO" "Downloading backup password" + local backup_password + backup_password=$(aws_s3_download_password "${password_file_path}") || { + log "ERROR" "Failed to download backup password" + set -e + exit 1 + } + + log "INFO" "Verifying the backup file from S3" + aws_s3_verify_file_integrity "${backup_file_path}" "${md5_file_path}" "${sha256_file_path}" || { + log "ERROR" "Failed to verify the backup file" + set -e + exit 1 + } + + set -x + set -o pipefail + + log "INFO" "Reset database before restore" + psql -d postgres <&2 +} + +# Function to monitor memory usage +function monitor_memory() { + local pid=$1 + local interval=${2-0.5} + local max_mem_mb=0 + local max_system_mem_mb=0 + local mem_kb + local mem_mb + local system_mem_bytes + local system_mem_mb + local start_time + start_time=$(date +%s) # Record start time in seconds + + # Path to the container's memory cgroup + local MEM_CGROUP_PATH="/sys/fs/cgroup/memory" + + # Trap to handle script exits and interruptions + local exit_code duration end_time + trap 'exit_code=$?; \ + end_time=$(date +%s); \ + duration=$((end_time - start_time)); \ + log "STAT" "Exit code: $exit_code"; \ + log "STAT" "Maximum memory used by the process: $max_mem_mb MB"; \ + log "STAT" "Maximum container memory used: $max_system_mem_mb MB"; \ + log "STAT" "Duration of the run: $duration seconds from $start_time to $end_time"; \ + exit $exit_code' EXIT SIGINT SIGTERM + + # Monitor memory usage + log "INFO" "Monitoring started at: $start_time"; + while true; do + if [ ! -e "/proc/$pid" ]; then + break + fi + # Process-specific memory in kilobytes, then convert to megabytes + mem_kb=$(awk '/VmRSS/{print $2}' "/proc/$pid/status" 2>/dev/null) + mem_mb=$((mem_kb / 1024)) + if [[ "$mem_mb" -gt "$max_mem_mb" ]]; then + max_mem_mb=$mem_mb + fi + + # Container-specific memory (used memory) in bytes, then convert to megabytes + system_mem_bytes=$(cat $MEM_CGROUP_PATH/memory.usage_in_bytes) + system_mem_mb=$((system_mem_bytes / 1024 / 1024)) + if [[ "$system_mem_mb" -gt "$max_system_mem_mb" ]]; then + max_system_mem_mb=$system_mem_mb + fi + + sleep "$interval" + done +} + +# Check if an argument is passed +if [ -z "$1" ]; then + echo "Error: No path to the JavaScript file provided." + echo "Usage: ./run_process_data.sh " + exit 1 +fi + +# Change to the application directory +cd /home/vcap/app || exit + +echo "Current directory:" $(pwd) >&2 +echo "JS File Path:" $1 >&2 +echo "env:" $(env) >&2 + +# Set the PATH from the lifecycle environment +export PATH=/home/vcap/deps/0/bin:/bin:/usr/bin:/home/vcap/app/bin:/home/vcap/app/node_modules/.bin + +# Get the total memory limit from cgroup in bytes +MEMORY_LIMIT_BYTES=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) + +# Convert bytes to megabytes +MEMORY_LIMIT_MB=$(($MEMORY_LIMIT_BYTES / 1024 / 1024)) + +# Calculate 80% of the MEMORY_LIMIT for max-old-space-size +MAX_OLD_SPACE_SIZE=$((MEMORY_LIMIT_MB * 8 / 10)) + +# Round to the nearest whole number +MAX_OLD_SPACE_SIZE=${MAX_OLD_SPACE_SIZE%.*} + +# Calculate 1% of MEMORY_LIMIT for max-semi-space-size with a minimum of 16 MB +# 1% of MEMORY_LIMIT or 16 MB, whichever is larger +MAX_SEMI_SPACE_SIZE=$((MEMORY_LIMIT_MB / 100)) +if [ "$MAX_SEMI_SPACE_SIZE" -lt 16 ]; then + MAX_SEMI_SPACE_SIZE=16 +fi + +# Start memory monitoring in the background +monitor_memory $$ & + +# Run the Node.js script +echo "node --max-old-space-size=$MAX_OLD_SPACE_SIZE --max-semi-space-size=$MAX_SEMI_SPACE_SIZE --expose-gc $1" >&2 +node --max-old-space-size=$MAX_OLD_SPACE_SIZE --max-semi-space-size=$MAX_SEMI_SPACE_SIZE --expose-gc $1 + +# Capture the exit code of the Node.js command +SHELL_EXIT_CODE=$? + +echo "Script exited with code $SHELL_EXIT_CODE" >&2 + +# Exit the script with the same exit code +exit $SHELL_EXIT_CODE diff --git a/bin/README.md b/bin/README.md index 5caa9b38cc..0d5ad2a126 100644 --- a/bin/README.md +++ b/bin/README.md @@ -1,6 +1,6 @@ # S3 Backup Retrieval Tool -This script, named `latest_backup.sh`, is designed to interact with Cloud Foundry's S3 service instances to perform various operations such as creating service keys, retrieving and verifying AWS credentials, generating presigned URLs for files stored in an S3 bucket, and handling the deletion of service keys post-operation based on user preference. Additionally, it can list all ZIP files in the S3 folder, download and verify specific backup files, and erase files from S3. +This script, named `latest_backup.sh`, is designed to interact with Cloud Foundry's S3 service instances to perform various operations such as creating service keys, retrieving and verifying AWS credentials, generating presigned URLs for files stored in an S3 bucket, and handling the deletion of service keys post-operation based on user preference. Additionally, it can list all ZIP and ZENC files in the S3 folder, download and verify specific backup files, and erase files from S3. ## Features @@ -9,9 +9,9 @@ This script, named `latest_backup.sh`, is designed to interact with Cloud Foundr - **AWS Credential Verification**: Verifies that AWS credentials are valid. - **Backup File Retrieval**: Retrieves the path for the latest backup file from an S3 bucket and downloads it. - **Presigned URL Generation**: Generates AWS S3 presigned URLs for the specified files, allowing secure, temporary access without requiring AWS credentials. -- **File Listing**: Lists all ZIP files in the specified S3 folder, along with their corresponding pwd, md5, sha256 files, sizes, and ages. +- **File Listing**: Lists all ZIP and ZENC files in the specified S3 folder, along with their corresponding pwd, md5, sha256 files, sizes, and ages. - **Download and Verification**: Downloads a specific backup file and verifies its integrity using MD5 and SHA-256 checksums. -- **File Erasure**: Deletes a specified set of files (ZIP, pwd, md5, sha256) from S3. +- **File Erasure**: Deletes a specified set of files (ZIP, ZENC, pwd, md5, sha256) from S3. - **Old Service Key Deletion**: Deletes service keys older than 6 hours. - **Clean-up Options**: Optionally deletes the service key used during the operations to maintain security. @@ -33,7 +33,7 @@ To use this script, you may need to provide various arguments based on the requi 1. **CF_S3_SERVICE_NAME**: The name of the Cloud Foundry S3 service instance (optional, default: `ttahub-db-backups`). 2. **s3_folder**: The specific folder within the S3 bucket where `latest-backup.txt` is located (optional, default: `production`). 3. **DELETION_ALLOWED**: Whether to allow deletion of the service key post-operation. Set this to 'yes' to enable deletion (optional, default: `no`). -4. **list_zip_files**: Whether to list all ZIP files in the S3 folder. Set this to 'yes' to enable listing (optional, default: `no`). +4. **list_backup_files**: Whether to list all ZIP and ZENC files in the S3 folder. Set this to 'yes' to enable listing (optional, default: `no`). 5. **specific_file**: The specific backup file to process (optional). 6. **download_and_verify**: Whether to download and verify the specific backup file. Set this to 'yes' to enable download and verification (optional, default: `no`). 7. **erase_file**: The specific file to erase from S3 (optional). @@ -42,7 +42,7 @@ To use this script, you may need to provide various arguments based on the requi ### Basic Command ```bash -./latest_backup.sh [--service-name ] [--s3-folder ] [--allow-deletion] [--list-zip-files] [--specific-file ] [--download-and-verify] [--erase-file ] [--delete-old-keys] +./latest_backup.sh [--service-name ] [--s3-folder ] [--allow-deletion] [--list-backup-files] [--specific-file ] [--download-and-verify] [--erase-file ] [--delete-old-keys] ``` ### Example Commands @@ -53,10 +53,10 @@ To use this script, you may need to provide various arguments based on the requi ./latest_backup.sh ``` -- List all ZIP files in the specified S3 folder: +- List all ZIP and ZENC files in the specified S3 folder: ```bash -./latest_backup.sh --list-zip-files +./latest_backup.sh --list-backup-files ``` - Download and verify a specific backup file: diff --git a/bin/latest_backup.sh b/bin/latest_backup.sh index f4918a2c4b..a3d670d82c 100644 --- a/bin/latest_backup.sh +++ b/bin/latest_backup.sh @@ -80,7 +80,7 @@ delete_service_key() { # Function to delete older service keys delete_old_service_keys() { local cf_s3_service_name=$1 - local current_service_key=$1 + local current_service_key=$2 local current_time=$(date +%s) local six_hours_in_seconds=21600 echo "Deleting older service keys for service instance ${cf_s3_service_name}..." @@ -151,41 +151,52 @@ generate_presigned_urls() { echo "${urls[@]}" } -# Function to list all ZIP files in the same S3 path as the latest backup -list_all_zip_files() { +# Function to list all ZIP and ZENC files in the same S3 path as the latest backup +list_all_backup_files() { local bucket_name=$1 local s3_folder=$2 - local zip_files=$(aws s3 ls "s3://${bucket_name}/${s3_folder}" --recursive | grep '.zip\|.pwd\|.md5\|.sha256') - if [ -z "${zip_files}" ]; then - echo "No ZIP files found in S3 bucket." + local backup_files=$(aws s3 ls "s3://${bucket_name}/${s3_folder}" --recursive | grep -E '\.zip|\.zenc|\.pwd|\.md5|\.sha256') + if [ -z "${backup_files}" ]; then + echo "No backup files found in S3 bucket." else - echo "ZIP files in S3 bucket:" - printf "%-50s %-5s %-5s %-5s %-15s %-5s\n" "Name" "pwd" "md5" "sha256" "size(zip)" "age(days)" + echo "Backup files in S3 bucket:" + printf "%-50s %-7s %-5s %-5s %-5s %-15s %-5s\n" "Name" "Format" "pwd" "md5" "sha256" "size" "age(days)" current_date=$(date +%s) - echo "${zip_files}" | \ - while read line; do \ + echo "${backup_files}" | \ + while IFS= read -r line; do \ echo "${line##*.} ${line}";\ done |\ sort -rk5 |\ tr '\n' ' ' | \ - sed 's~ zip ~\nzip ~g' |\ - while read line; do - zip_file=$(echo ${line} | awk '{split($5, a, "/"); print a[length(a)]}'); - has_pwd=$([[ $line == *" pwd "* ]] && echo "x" || echo ""); - has_md5=$([[ $line == *" md5 "* ]] && echo "x" || echo ""); - has_sha256=$([[ $line == *" sha256 "* ]] && echo "x" || echo ""); - zip_size=$(numfmt --to=iec-i --suffix=B $(echo ${line} | awk '{print $4}')); - - # Determine OS and use appropriate date command + sed 's~ \(zip\|zenc\) ~\n& ~g' |\ + sed -r 's/^[ \t]*//g' |\ + sed -r 's/[ \t]+/ /g' |\ + awk '{print $0 "\n"}' | \ + while IFS= read -r line; do + backup_file=$(echo "${line}" | awk '{split($5, a, "/"); print a[length(a)]}') + format=$(echo "${line}" | awk '{print $1}') + has_pwd=$([[ $line == *" pwd "* ]] && echo "x" || echo "") + has_md5=$([[ $line == *" md5 "* ]] && echo "x" || echo "") + has_sha256=$([[ $line == *" sha256 "* ]] && echo "x" || echo "") + + # Extract the size and validate it's numeric before passing to numfmt + backup_size=$(echo "${line}" | awk '{print $4}') + if [[ "$backup_size" =~ ^[0-9]+$ ]]; then + backup_size=$(numfmt --to=iec-i --suffix=B "$backup_size") + else + backup_size="N/A" # Handle cases where the size is not a number + fi + if [[ "$OSTYPE" == "darwin"* ]]; then - zip_age=$(( ( $(date +%s) - $(date -j -f "%Y-%m-%d" "$(echo ${line} | awk '{print $2}')" +%s) ) / 86400 )) + backup_age=$(( ( $(date +%s) - $(date -j -f "%Y-%m-%d" "$(echo "${line}" | awk '{print $2}')" +%s) ) / 86400 )) else - zip_age=$(( ( $(date +%s) - $(date -d "$(echo ${line} | awk '{print $2}')" +%s) ) / 86400 )) + backup_age=$(( ( $(date +%s) - $(date -d "$(echo "${line}" | awk '{print $2}')" +%s) ) / 86400 )) fi - printf "%-50s %-5s %-5s %-5s %-15s %-5s\n" "$zip_file" "$has_pwd" "$has_md5" "$has_sha256" "$zip_size" "$zip_age"; + printf "%-50s %-7s %-5s %-5s %-5s %-15s %-5s\n" "$backup_file" "$format" "$has_pwd" "$has_md5" "$has_sha256" "$backup_size" "$backup_age" done |\ - sort -k1 + sort -k1 |\ + grep -v "N/A" fi } @@ -202,20 +213,21 @@ verify_file_exists() { # Function to download and verify files download_and_verify() { - local zip_url=$1 - local zip_file_name=$2 + local backup_url=$1 + local backup_file_name=$2 local password_url=$3 local md5_url=$4 local sha256_url=$5 + local format=$6 # Check if wget is installed if command -v wget &>/dev/null; then echo "Using wget to download the file." - wget -O "$zip_file_name" "$zip_url" + downloader="wget -O -" else # If wget is not installed, use curl echo "wget is not installed. Using curl to download the file." - curl -o "$zip_file_name" "$zip_url" + downloader="curl -s" fi # Download password, SHA-256 checksum, and MD5 checksum directly into variables @@ -223,59 +235,82 @@ download_and_verify() { local checksum_sha256=$(curl -s "$sha256_url") local checksum_md5=$(curl -s "$md5_url") + # Download file and generate hashes simultaneously + echo "Downloading file and generating hashes..." + $downloader "$backup_url" |\ + tee >(sha256sum | awk '{print $1}' > "${backup_file_name}.sha256") \ + >(md5sum | awk '{print $1}' > "${backup_file_name}.md5") \ + > "$backup_file_name" + # Verify SHA-256 checksum echo "Verifying SHA-256 checksum..." - echo "$checksum_sha256 $zip_file_name" | sha256sum -c - if [ $? -ne 0 ]; then + if [[ $(cat "${backup_file_name}.sha256") != "$checksum_sha256" ]]; then echo "SHA-256 checksum verification failed." exit 1 else echo "SHA-256 checksum verified." fi + rm "${backup_file_name}.sha256" # Verify MD5 checksum echo "Verifying MD5 checksum..." - echo "$checksum_md5 $zip_file_name" | md5sum -c - if [ $? -ne 0 ]; then + if [[ $(cat "${backup_file_name}.md5") != "$checksum_md5" ]]; then echo "MD5 checksum verification failed." exit 1 else echo "MD5 checksum verified." fi + rm "${backup_file_name}.md5" - # Unzip the file - echo "Unzipping the file..." - unzip -P "$password" "$zip_file_name" - if [ $? -eq 0 ]; then - echo "File unzipped successfully." - - # Rename the extracted file - extracted_file="-" - new_name="${zip_file_name%.zip}" - mv "$extracted_file" "$new_name" + if [ "$format" = "zip" ]; then + # Unzip the file + echo "Unzipping the file..." + unzip -P "$password" "$backup_file_name" if [ $? -eq 0 ]; then - echo "File renamed to $new_name." + echo "File unzipped successfully." + # Rename the extracted file + extracted_file=$(unzip -l "$backup_file_name" | awk 'NR==4 {print $4}') + new_name="${backup_file_name%.zip}" + mv "$extracted_file" "$new_name" + if [ $? -eq 0 ]; then + echo "File renamed to $new_name." + else + echo "Failed to rename the file." + exit 1 + fi else - echo "Failed to rename the file." + echo "Failed to unzip the file." + exit 1 + fi + elif [ "$format" = "zenc" ]; then + # Decrypt and decompress the already downloaded file + echo "Decrypting and decompressing the file..." + openssl enc -d -aes-256-cbc -salt -pbkdf2 -k "${password}" -in "$backup_file_name" |\ + gzip -d -c > "${backup_file_name%.zenc}" + if [ $? -eq 0 ]; then + echo "File decrypted and decompressed successfully: ${backup_file_name%.zenc}" + else + echo "Failed to decrypt and decompress the file." exit 1 fi else - echo "Failed to unzip the file." + echo "Unknown backup format: $format" exit 1 fi } + # Function to erase a set of files from S3 erase_files() { local bucket_name=$1 local s3_folder=$2 - local zip_file=$3 + local backup_file=$3 - local pwd_file="${zip_file%.zip}.pwd" - local md5_file="${zip_file%.zip}.md5" - local sha256_file="${zip_file%.zip}.sha256" + local pwd_file="${backup_file%.zip}.pwd" + local md5_file="${backup_file%.zip}.md5" + local sha256_file="${backup_file%.zip}.sha256" - local files_to_delete=("$zip_file" "$pwd_file" "$md5_file" "$sha256_file") + local files_to_delete=("$backup_file" "$pwd_file" "$md5_file" "$sha256_file") echo "Deleting files from S3:" for file in "${files_to_delete[@]}"; do @@ -294,13 +329,12 @@ erase_files() { done } - # Function to retrieve and use S3 service credentials fetch_latest_backup_info_and_cleanup() { local cf_s3_service_name="${cf_s3_service_name:-ttahub-db-backups}" # Default to 'db-backups' if not provided local s3_folder="${s3_folder:-production}" # Default to root of the bucket if not provided local deletion_allowed="${deletion_allowed:-no}" # Default to no deletion if not provided - local list_zip_files="${list_zip_files:-no}" # Default to no listing of ZIP files if not provided + local list_backup_files="${list_backup_files:-no}" # Default to no listing of ZIP files if not provided local specific_file="${specific_file:-}" local download_and_verify="${download_and_verify:-no}" local erase_file="${erase_file:-}" @@ -332,9 +366,9 @@ fetch_latest_backup_info_and_cleanup() { elif [ "${erase_file}" != "" ]; then # Erase the specified file along with its corresponding pwd, md5, and sha256 files erase_files "$bucket_name" "$s3_folder" "$erase_file" - elif [ "${list_zip_files}" = "yes" ]; then - # List all ZIP files if the option is enabled - list_all_zip_files "$bucket_name" "$s3_folder" + elif [ "${list_backup_files}" = "yes" ]; then + # List all ZIP and ZENC files if the option is enabled + list_all_backup_files "$bucket_name" "$s3_folder" else if [ -n "$specific_file" ]; then backup_file_name="${s3_folder}/${specific_file}" @@ -357,13 +391,22 @@ fetch_latest_backup_info_and_cleanup() { local sha256_file_name="${backup_file_name%.zip}.sha256" local password_file_name="${backup_file_name%.zip}.pwd" + # Determine the backup format + local format="zip" + if [[ "$backup_file_name" == *.zenc ]]; then + format="zenc" + md5_file_name="${backup_file_name%.zenc}.md5" + sha256_file_name="${backup_file_name%.zenc}.sha256" + password_file_name="${backup_file_name%.zenc}.pwd" + fi + # Generate presigned URLs for these files local urls IFS=' ' read -r -a urls <<< "$(generate_presigned_urls "$bucket_name" "$backup_file_name" "$password_file_name" "$md5_file_name" "$sha256_file_name")" if [ "${download_and_verify}" = "yes" ]; then # Perform download and verify functionality - download_and_verify "${urls[0]}" "$(basename "$backup_file_name")" "${urls[1]}" "${urls[2]}" "${urls[3]}" + download_and_verify "${urls[0]}" "$(basename "$backup_file_name")" "${urls[1]}" "${urls[2]}" "${urls[3]}" "$format" else # Print presigned URLs echo "Presigned URLs for the files:" @@ -385,12 +428,12 @@ while [[ "$#" -gt 0 ]]; do -n|--service-name) cf_s3_service_name="$2"; shift ;; -s|--s3-folder) s3_folder="$2"; shift ;; -a|--allow-deletion) deletion_allowed="yes" ;; - -l|--list-zip-files) list_zip_files="yes" ;; + -l|--list-backup-files) list_backup_files="yes" ;; -f|--specific-file) specific_file="$2"; shift ;; -d|--download-and-verify) download_and_verify="yes"; deletion_allowed="yes" ;; -e|--erase-file) erase_file="$2"; shift ;; -k|--delete-old-keys) delete_old_keys="yes" ;; - -h|--help) echo "Usage: $0 [-n | --service-name ] [-s | --s3-folder ] [-a | --allow-deletion] [-l | --list-zip-files] [-f | --specific-file ] [-d | --download-and-verify] [-e | --erase-file ] [-k | --delete-old-keys]"; exit 0 ;; + -h|--help) echo "Usage: $0 [-n | --service-name ] [-s | --s3-folder ] [-a | --allow-deletion] [-l | --list-backup-files] [-f | --specific-file ] [-d | --download-and-verify] [-e | --erase-file ] [-k | --delete-old-keys]"; exit 0 ;; *) echo "Unknown parameter passed: $1"; exit 12 ;; esac shift diff --git a/docker-compose.override.yml b/docker-compose.override.yml index e1af9c2ddf..938432398f 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -3,7 +3,7 @@ services: build: context: . profiles: - - minimal_required + - minimal_required_node command: yarn server user: ${CURRENT_USER:-root} ports: @@ -35,7 +35,7 @@ services: build: context: . profiles: - - minimal_required + - minimal_required_node command: yarn start user: ${CURRENT_USER:-root} stdin_open: true @@ -54,7 +54,7 @@ services: build: context: . profiles: - - minimal_required + - minimal_required_node command: yarn worker env_file: .env depends_on: @@ -71,7 +71,7 @@ services: owasp_zap_backend: image: softwaresecurityproject/zap-stable:latest profiles: - - full_stack + - full_stack_zap platform: linux/arm64 user: zap command: zap-full-scan.py -t http://backend:8080 -c zap.conf -i -r owasp_report_.html @@ -83,7 +83,7 @@ services: owasp_zap_similarity: image: softwaresecurityproject/zap-stable:latest profiles: - - full_stack + - full_stack_zap platform: linux/arm64 user: zap command: zap-api-scan.py -t http://similarity:8080/openapi.json -f openapi -I -i -r owasp_api_report.html diff --git a/docker-compose.yml b/docker-compose.yml index 1931e66c4e..d411d83ac3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: container_name: postgres_docker env_file: .env profiles: - - minimal_required + - minimal_required_postgres ports: - "5432:5432" volumes: @@ -51,7 +51,7 @@ services: build: context: ./similarity_api profiles: - - minimal_required + - minimal_required_python ports: - "9100:8080" env_file: .env @@ -62,7 +62,7 @@ services: redis: image: redis:5.0.6-alpine profiles: - - minimal_required + - minimal_required_redis command: ['redis-server', '--requirepass', '$REDIS_PASS'] env_file: .env ports: diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index e54c2637a7..ef38c648e2 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -928,10 +928,10 @@ class Programs{ * grantId : integer : REFERENCES "Grants".id * createdAt : timestamp with time zone : now() * updatedAt : timestamp with time zone : now() - endDate : varchar(255) + endDate : date name : varchar(255) programType : varchar(255) - startDate : varchar(255) + startDate : date startYear : varchar(255) status : varchar(255) } diff --git a/frontend/package.json b/frontend/package.json index 4bb47f5d4d..1cbf1b6315 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "@react-hook/resize-observer": "^1.2.6", "@silevis/reactgrid": "3.1", "@trussworks/react-uswds": "4.1.1", - "@ttahub/common": "^2.1.5", + "@ttahub/common": "^2.1.7", "@use-it/interval": "^1.0.0", "async": "^3.2.3", "browserslist": "^4.16.5", @@ -74,6 +74,7 @@ "test:ci": "cross-env TZ=America/New_York JEST_JUNIT_OUTPUT_DIR=reports JEST_JUNIT_OUTPUT_NAME=unit.xml CI=true node --expose-gc ./node_modules/.bin/react-scripts test --coverage --silent --reporters=default --detectOpenHandles --forceExit --reporters=jest-junit --logHeapUsage", "lint": "eslint src", "lint:fix": "eslint --fix src", + "lint:fix:single": "eslint --fix", "lint:ci": "eslint -f eslint-formatter-multiple src" }, "resolutions": { diff --git a/frontend/src/components/BuildInfo.js b/frontend/src/components/BuildInfo.js new file mode 100644 index 0000000000..610afed7cd --- /dev/null +++ b/frontend/src/components/BuildInfo.js @@ -0,0 +1,51 @@ +import React, { useEffect, useState } from 'react'; + +function BuildInfo() { + const [buildInfo, setBuildInfo] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + fetch('/api/admin/buildInfo') + .then((response) => { + if (!response.ok) throw new Error('Build info not accessible'); + return response.json(); + }) + .then((data) => { + setBuildInfo(data); + setError(false); + }) + .catch(() => { + setError(true); // Set error state if fetch fails + }); + }, []); + + if (error || !buildInfo) return null; // Show nothing if there's an error or no build info + + return ( +
+

+ Branch:  + {buildInfo.branch} +
+ Commit:  + {buildInfo.commit} +
+ Build Number:  + {buildInfo.buildNumber} +
+ Deployed on:  + {buildInfo.timestamp} +
+

+
+ ); +} + +export default BuildInfo; diff --git a/frontend/src/components/__tests__/BuildInfo.js b/frontend/src/components/__tests__/BuildInfo.js new file mode 100644 index 0000000000..13b2bcbe3d --- /dev/null +++ b/frontend/src/components/__tests__/BuildInfo.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import BuildInfo from '../BuildInfo'; + +// Mock the fetch API globally for tests +global.fetch = jest.fn(); + +const mockBuildInfo = { + branch: 'main', + commit: 'abcdef12345', + buildNumber: '123', + timestamp: '2024-11-13 12:34:56', +}; + +describe('BuildInfo', () => { + beforeEach(() => { + fetch.mockClear(); + }); + + it('renders build information when API call is successful', async () => { + // Mock successful fetch response + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockBuildInfo, + }); + + render(); + + // Wait for build info to be rendered + await waitFor(() => { + expect(screen.getByText(/Branch:/)).toBeInTheDocument(); + expect(screen.getByText(/main/)).toBeInTheDocument(); + expect(screen.getByText(/Commit:/)).toBeInTheDocument(); + expect(screen.getByText(/abcdef12345/)).toBeInTheDocument(); + expect(screen.getByText(/Build Number:/)).toBeInTheDocument(); + expect(screen.getByText(/123/)).toBeInTheDocument(); + expect(screen.getByText(/Deployed on:/)).toBeInTheDocument(); + expect(screen.getByText(/2024-11-13 12:34:56/)).toBeInTheDocument(); + }); + }); + + it('does not render build information if API call fails', async () => { + // Mock failed fetch response + fetch.mockRejectedValueOnce(new Error('API is down')); + + render(); + + // Wait to confirm no content is displayed + await waitFor(() => { + expect(screen.queryByText(/Branch:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Commit:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Build Number:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Deployed on:/)).not.toBeInTheDocument(); + }); + }); + + it('renders null when response.ok is false', async () => { + // Mock response with ok: false to simulate failure + fetch.mockResolvedValueOnce({ + ok: false, + json: async () => mockBuildInfo, + }); + + const { container } = render(); + + // Wait to confirm that nothing is rendered due to error + await waitFor(() => { + expect(container.firstChild).toBeNull(); + }); + }); +}); diff --git a/frontend/src/components/__tests__/HeaderUserMenu.js b/frontend/src/components/__tests__/HeaderUserMenu.js index a6edc9944f..752e4816ff 100644 --- a/frontend/src/components/__tests__/HeaderUserMenu.js +++ b/frontend/src/components/__tests__/HeaderUserMenu.js @@ -10,6 +10,13 @@ import userEvent from '@testing-library/user-event'; import App from '../../App'; import { mockRSSData, mockWindowProperty, mockDocumentProperty } from '../../testHelpers'; +const mockBuildInfo = { + branch: 'main', + commit: 'abcdef12345', + buildNumber: '123', + timestamp: '2024-11-13 12:34:56', +}; + describe('HeaderUserMenu', () => { const user = { name: 'harry potter', permissions: [] }; const adminUser = { @@ -21,6 +28,7 @@ describe('HeaderUserMenu', () => { const cleanupUrl = join('api', 'activity-reports', 'storage-cleanup'); const feedUrl = join('api', 'feeds', 'whats-new'); const groupsUrl = join('api', 'groups'); + const buildInfoUrl = '/api/admin/buildInfo'; // Add build info URL here const before = async (admin = false) => { if (admin) { @@ -33,6 +41,7 @@ describe('HeaderUserMenu', () => { fetchMock.get(cleanupUrl, []); fetchMock.get(feedUrl, mockRSSData()); fetchMock.get(groupsUrl, []); + fetchMock.get(buildInfoUrl, mockBuildInfo); // Mock build info response render(); @@ -122,6 +131,7 @@ describe('HeaderUserMenu', () => { describe('when unauthenticated', () => { beforeEach(async () => { fetchMock.get(userUrl, 401); + fetchMock.get(buildInfoUrl, mockBuildInfo); // Ensure buildInfo mock exists here too render(); await screen.findByText('Office of Head Start TTA Hub'); }); diff --git a/frontend/src/hooks/useMediaCapture.js b/frontend/src/hooks/useMediaCapture.js index 4aea23611a..eaf463ad61 100644 --- a/frontend/src/hooks/useMediaCapture.js +++ b/frontend/src/hooks/useMediaCapture.js @@ -1,13 +1,15 @@ import { useCallback } from 'react'; +import moment from 'moment'; import html2canvas from 'html2canvas'; -export default function useMediaCapture(reference, title) { +export default function useMediaCapture(reference, title = 'download') { const capture = useCallback(async () => { try { // capture the element, setting the width and height // we just calculated, and setting the background to white // and then converting it to a data url // and triggering a download + const today = moment().format('YYYY-MM-DD'); const canvas = await html2canvas(reference.current, { onclone: (_document, element) => { // set the first child to be white (we can always make this configurable later) @@ -20,7 +22,7 @@ export default function useMediaCapture(reference, title) { const base64image = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = base64image; - a.setAttribute('download', `${title}.png`); + a.setAttribute('download', `${today}-${title}.png`); a.click(); } catch (e) { // eslint-disable-next-line no-console diff --git a/frontend/src/pages/Admin/__tests__/index.js b/frontend/src/pages/Admin/__tests__/index.js index 06250b80b5..d8006c40f5 100644 --- a/frontend/src/pages/Admin/__tests__/index.js +++ b/frontend/src/pages/Admin/__tests__/index.js @@ -14,6 +14,7 @@ const grantsUrl = join('/', 'api', 'admin', 'grants', 'cdi?unassigned=false&acti const recipientsUrl = join('/', 'api', 'admin', 'recipients'); const usersUrl = join('/', 'api', 'admin', 'users'); const featuresUrl = join('/api', 'admin', 'users', 'features'); +const buildInfoUrl = '/api/admin/buildInfo'; describe('Admin landing page', () => { const history = createMemoryHistory(); @@ -25,6 +26,12 @@ describe('Admin landing page', () => { fetchMock.get(recipientsUrl, []); fetchMock.get(usersUrl, []); fetchMock.get(featuresUrl, []); + fetchMock.get(buildInfoUrl, { + branch: 'main', + commit: 'abcdef12345', + buildNumber: '123', + timestamp: '2024-11-13 12:34:56', + }); }); it('displays the cdi page', async () => { @@ -72,6 +79,7 @@ describe('Admin landing page', () => { const requestErrors = await screen.findByRole('heading', { name: /requesterrors/i }); expect(requestErrors).toBeVisible(); }); + it('displays the site alerts page', async () => { fetchMock.get('/api/admin/alerts', []); history.push('/admin/site-alerts'); diff --git a/frontend/src/pages/Admin/index.js b/frontend/src/pages/Admin/index.js index e0c76fce71..2b4cf34540 100644 --- a/frontend/src/pages/Admin/index.js +++ b/frontend/src/pages/Admin/index.js @@ -14,6 +14,7 @@ import TrainingReports from './TrainingReports'; import Courses from './Courses'; import CourseEdit from './CourseEdit'; import FeedPreview from './FeedPreview'; +import BuildInfo from '../../components/BuildInfo'; function Admin() { return ( @@ -115,6 +116,7 @@ function Admin() { render={() => } /> + ); } diff --git a/frontend/src/pages/RegionalDashboard/__tests__/index.js b/frontend/src/pages/RegionalDashboard/__tests__/index.js index 53daf3b755..f2e882f3c0 100644 --- a/frontend/src/pages/RegionalDashboard/__tests__/index.js +++ b/frontend/src/pages/RegionalDashboard/__tests__/index.js @@ -24,11 +24,23 @@ const reasonListUrl = join('api', 'widgets', 'reasonList'); const reasonListResponse = [{ name: 'Ongoing Quality Improvement', count: 3 }]; const totalHrsAndRecipientGraphUrl = join('api', 'widgets', 'totalHrsAndRecipientGraph'); const totalHoursResponse = [{ - name: 'Hours of Training', x: ['17', '18', '23', '2', '3'], y: [1.5, 0, 0, 0, 0], month: ['Nov', 'Nov', 'Nov', 'Dec', 'Dec'], + name: 'Hours of Training', + x: ['17', '18', '23', '2', '3'], + y: [1.5, 0, 0, 0, 0], + month: ['Nov', 'Nov', 'Nov', 'Dec', 'Dec'], + trace: 'circle', }, { - name: 'Hours of Technical Assistance', x: ['17', '18', '23', '2', '3'], y: [0, 0, 2.5, 2.5, 0], month: ['Nov', 'Nov', 'Nov', 'Dec', 'Dec'], + name: 'Hours of Technical Assistance', + x: ['17', '18', '23', '2', '3'], + y: [0, 0, 2.5, 2.5, 0], + month: ['Nov', 'Nov', 'Nov', 'Dec', 'Dec'], + trace: 'square', }, { - name: 'Hours of Both', x: ['17', '18', '23', '2', '3'], y: [1.5, 1.5, 0, 0, 3.5], month: ['Nov', 'Nov', 'Nov', 'Dec', 'Dec'], + name: 'Hours of Both', + x: ['17', '18', '23', '2', '3'], + y: [1.5, 1.5, 0, 0, 3.5], + month: ['Nov', 'Nov', 'Nov', 'Dec', 'Dec'], + trace: 'triangle', }]; const topicFrequencyGraphUrl = join('api', 'widgets', 'topicFrequencyGraph'); const topicFrequencyResponse = [{ topic: 'Behavioral / Mental Health / Trauma', count: 0 }, { topic: 'Child Screening and Assessment', count: 0 }]; diff --git a/frontend/src/pages/RegionalGoalDashboard/__tests__/index.js b/frontend/src/pages/RegionalGoalDashboard/__tests__/index.js index b63898db4b..35e04bae87 100644 --- a/frontend/src/pages/RegionalGoalDashboard/__tests__/index.js +++ b/frontend/src/pages/RegionalGoalDashboard/__tests__/index.js @@ -3,6 +3,7 @@ import { render, screen, act, within, } from '@testing-library/react'; import moment from 'moment'; +import { TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS } from '@ttahub/common/src/constants'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock'; import join from 'url-join'; @@ -30,18 +31,24 @@ const WIDGET_MOCKS = { x: ['Jul-22', 'Aug-22', 'Sep-22'], y: [338.5, 772, 211], month: [false, false, false], + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.TRAINING, + trace: 'square', }, { name: 'Hours of Technical Assistance', x: ['Jul-22', 'Aug-22', 'Sep-22'], y: [279.5, 274.5, 155.5], month: [false, false, false], + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.TECHNICAL_ASSISTANCE, + trace: 'circle', }, { name: 'Hours of Both', x: ['Jul-22', 'Aug-22', 'Sep-22'], y: [279.5, 274.5, 155.5], month: [false, false, false], + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.BOTH, + trace: 'triangle', }, ], }; diff --git a/frontend/src/widgets/DeliveryMethodGraph.js b/frontend/src/widgets/DeliveryMethodGraph.js index 109a6d80cd..5bc1b05622 100644 --- a/frontend/src/widgets/DeliveryMethodGraph.js +++ b/frontend/src/widgets/DeliveryMethodGraph.js @@ -33,10 +33,17 @@ const DEFAULT_SORT_CONFIG = { }; const KEY_COLUMNS = ['Months']; +const EXPORT_NAME = 'Delivery Method'; + +const TRACE_IDS = { + IN_PERSON: 'in-person', + VIRTUAL: 'virtual', + HYBRID: 'hybrid', +}; export default function DeliveryMethodGraph({ data }) { const widgetRef = useRef(null); - const capture = useMediaCapture(widgetRef, 'Total TTA hours'); + const capture = useMediaCapture(widgetRef, EXPORT_NAME); const [showTabularData, setShowTabularData] = useState(false); const [checkboxes, setCheckboxes] = useState({}); const [displayFilteredReports, setDisplayFilteredReports] = useState(0); @@ -74,7 +81,7 @@ export default function DeliveryMethodGraph({ data }) { TABLE_HEADINGS, checkboxes, 'Months', - 'DeliveryMethod', + EXPORT_NAME, ); // records is an array of objects @@ -122,13 +129,13 @@ export default function DeliveryMethodGraph({ data }) { // use a map for quick lookup const traceMap = new Map(); traceMap.set('Virtual', { - x: [], y: [], name: 'Virtual', traceOrder: 0, + x: [], y: [], name: 'Virtual', traceOrder: 0, id: TRACE_IDS.VIRTUAL, trace: 'triangle', }); traceMap.set('In person', { - x: [], y: [], name: 'In person', traceOrder: 1, + x: [], y: [], name: 'In person', traceOrder: 1, id: TRACE_IDS.IN_PERSON, trace: 'circle', }); traceMap.set('Hybrid', { - x: [], y: [], name: 'Hybrid', traceOrder: 2, + x: [], y: [], name: 'Hybrid', traceOrder: 2, id: TRACE_IDS.HYBRID, trace: 'square', }); (records || []).forEach((dataset, index) => { @@ -259,13 +266,13 @@ export default function DeliveryMethodGraph({ data }) { yAxisTitle="Percentage" legendConfig={[ { - label: 'In person', selected: true, shape: 'circle', id: 'show-in-person-checkbox', + label: 'In person', selected: true, shape: 'circle', id: 'show-in-person-checkbox', traceId: TRACE_IDS.IN_PERSON, }, { - label: 'Virtual', selected: true, shape: 'triangle', id: 'show-virtual-checkbox', + label: 'Virtual', selected: true, shape: 'triangle', id: 'show-virtual-checkbox', traceId: TRACE_IDS.VIRTUAL, }, { - label: 'Hybrid', selected: true, shape: 'square', id: 'show-hybrid-checkbox', + label: 'Hybrid', selected: true, shape: 'square', id: 'show-hybrid-checkbox', traceId: TRACE_IDS.HYBRID, }, ]} tableConfig={tableConfig} diff --git a/frontend/src/widgets/LineGraph.js b/frontend/src/widgets/LineGraph.js index 37c4708f74..7c9c06ba38 100644 --- a/frontend/src/widgets/LineGraph.js +++ b/frontend/src/widgets/LineGraph.js @@ -15,6 +15,73 @@ import NoResultsFound from '../components/NoResultsFound'; const HOVER_TEMPLATE = '(%{x}, %{y})'; +const TRACE_CONFIG = { + circle: (data) => ({ + id: data.id, + // Technical Assistance + type: 'scatter', + mode: 'lines+markers', + x: data.x, + y: data.y, + hovertemplate: HOVER_TEMPLATE, + line: { + dash: 'solid', + width: 3, + color: colors.ttahubBlue, + }, + marker: { + size: 12, + }, + hoverlabel: { + font: { color: '#ffffff', size: '16' }, + bgcolor: colors.textInk, + }, + }), + square: (data) => ({ + id: data.id, + type: 'scatter', + mode: 'lines+markers', + x: data.x, + y: data.y, + hovertemplate: HOVER_TEMPLATE, + line: { + dash: 'longdash', + width: 3, + color: colors.ttahubMediumDeepTeal, + }, + marker: { + symbol: 'square', + size: 12, + }, + hoverlabel: { + font: { color: '#ffffff', size: '16' }, + bgcolor: colors.textInk, + }, + }), + triangle: (data) => ({ + // Training + id: data.id, + type: 'scatter', + mode: 'lines+markers', + x: data.x, + y: data.y, + hovertemplate: HOVER_TEMPLATE, + line: { + dash: 'dash', + width: 3, + color: colors.ttahubOrange, + }, + marker: { + size: 14, + symbol: 'triangle-up', + }, + hoverlabel: { + font: { color: '#ffffff', size: '16' }, + bgcolor: colors.textInk, + }, + }), +}; + export default function LineGraph({ data, hideYAxis, @@ -125,74 +192,12 @@ export default function LineGraph({ }; } - // these are ordered from left to right how they appear in the checkboxes/legend - const traces = [ - { - // Technical Assistance - type: 'scatter', - mode: 'lines+markers', - x: data[1].x, - y: data[1].y, - hovertemplate: HOVER_TEMPLATE, - line: { - dash: 'solid', - width: 3, - color: colors.ttahubBlue, - }, - marker: { - size: 12, - }, - hoverlabel: { - font: { color: '#ffffff', size: '16' }, - bgcolor: colors.textInk, - }, - }, - // Both - { - type: 'scatter', - mode: 'lines+markers', - x: data[2].x, - y: data[2].y, - hovertemplate: HOVER_TEMPLATE, - line: { - dash: 'longdash', - width: 3, - color: colors.ttahubMediumDeepTeal, - }, - marker: { - symbol: 'square', - size: 12, - }, - hoverlabel: { - font: { color: '#ffffff', size: '16' }, - bgcolor: colors.textInk, - }, - }, - { - // Training - type: 'scatter', - mode: 'lines+markers', - x: data[0].x, - y: data[0].y, - hovertemplate: HOVER_TEMPLATE, - line: { - dash: 'dash', - width: 3, - color: colors.ttahubOrange, - }, - marker: { - size: 14, - symbol: 'triangle-up', - }, - hoverlabel: { - font: { color: '#ffffff', size: '16' }, - bgcolor: colors.textInk, - }, - }, - ]; + const traces = data.map((d) => TRACE_CONFIG[d.trace](d)); - const tracesToDraw = legends.map((legend, index) => (legend.selected ? traces[index] : null)) + const tracesToDraw = legends + .map((legend) => (legend.selected ? traces.find(({ id }) => id === legend.traceId) : null)) .filter((trace) => Boolean(trace)); + // draw the plot Plotly.newPlot(lines.current, tracesToDraw, layout, { displayModeBar: false, hovermode: 'none', responsive: true }); }, [data, hideYAxis, legends, showTabularData, xAxisTitle, yAxisTitle, hasData]); @@ -257,6 +262,7 @@ LineGraph.propTypes = { x: PropTypes.arrayOf(PropTypes.string), y: PropTypes.arrayOf(PropTypes.number), month: PropTypes.string, + id: PropTypes.string, }), ), hideYAxis: PropTypes.bool, diff --git a/frontend/src/widgets/PercentageActivityReportByRole.js b/frontend/src/widgets/PercentageActivityReportByRole.js index 2da2dc6082..fd044e19cf 100644 --- a/frontend/src/widgets/PercentageActivityReportByRole.js +++ b/frontend/src/widgets/PercentageActivityReportByRole.js @@ -24,9 +24,11 @@ const DEFAULT_SORT_CONFIG = { activePage: 1, }; +const EXPORT_NAME = 'Percentage of Activity Reports by Role'; + export default function PercentageActivityReportByRole({ data }) { const widgetRef = useRef(null); - const capture = useMediaCapture(widgetRef, 'Percentage of activity reports by role'); + const capture = useMediaCapture(widgetRef, EXPORT_NAME); const [showTabularData, setShowTabularData] = useState(false); const [checkboxes, setCheckboxes] = useState({}); const [displayFilteredReports, setDisplayFilteredReports] = useState(0); @@ -61,7 +63,7 @@ export default function PercentageActivityReportByRole({ data }) { TABLE_HEADINGS, checkboxes, FIRST_COLUMN, - 'PercentageARSByRole', + EXPORT_NAME, ); // records is an array of objects diff --git a/frontend/src/widgets/RootCauseFeiGoals.js b/frontend/src/widgets/RootCauseFeiGoals.js index 70637e9317..fa766efbb5 100644 --- a/frontend/src/widgets/RootCauseFeiGoals.js +++ b/frontend/src/widgets/RootCauseFeiGoals.js @@ -27,9 +27,11 @@ const DEFAULT_SORT_CONFIG = { activePage: 1, }; +const EXPORT_NAME = 'Root cause on FEI goals'; + export default function RootCauseFeiGoals({ data }) { const widgetRef = useRef(null); - const capture = useMediaCapture(widgetRef, 'RootCauseOnFeiGoals'); + const capture = useMediaCapture(widgetRef, EXPORT_NAME); const [showTabularData, setShowTabularData] = useState(false); const [checkboxes, setCheckboxes] = useState({}); @@ -62,7 +64,7 @@ export default function RootCauseFeiGoals({ data }) { TABLE_HEADINGS, checkboxes, FIRST_COLUMN, - 'PercentageARSByRole', + EXPORT_NAME, ); // records is an array of objects diff --git a/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js b/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js index 1ed19484f1..aa3530cf26 100644 --- a/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js +++ b/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js @@ -10,12 +10,14 @@ import { NOOP } from '../Constants'; const FIRST_HEADING = 'National Center'; const HEADINGS = ['Hours']; +const TITLE = 'Hours of training by National Center'; + const TRHoursWidget = ({ data, }) => { const widgetRef = useRef(null); const [showTabularData, setShowTabularData] = useState(false); - const capture = useMediaCapture(widgetRef, 'Total TTA hours'); + const capture = useMediaCapture(widgetRef, TITLE); const menuItems = [{ label: showTabularData ? 'Display graph' : 'Display table', diff --git a/frontend/src/widgets/TopicFrequencyGraph.js b/frontend/src/widgets/TopicFrequencyGraph.js index 695b9c4f4b..ef781349bc 100644 --- a/frontend/src/widgets/TopicFrequencyGraph.js +++ b/frontend/src/widgets/TopicFrequencyGraph.js @@ -1,5 +1,11 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { + useState, + useEffect, + useRef, + useMemo, +} from 'react'; import PropTypes from 'prop-types'; +import moment from 'moment'; import Plotly from 'plotly.js-basic-dist'; import { Grid } from '@trussworks/react-uswds'; import withWidgetData from './withWidgetData'; @@ -40,6 +46,11 @@ export function TopicFrequencyGraphWidget({ loading, title, }) { + const exportName = useMemo(() => { + const TODAY = moment().format('YYYY-MM-DD'); + return `${TODAY} ${title}`; + }, [title]); + // whether to show the data as accessible widget data or not const [showAccessibleData, setShowAccessibleData] = useState(false); @@ -191,7 +202,7 @@ export function TopicFrequencyGraphWidget({ buttonText="Save screenshot" id="rd-save-screenshot-topic-frequency" className="margin-x-2" - title={title} + title={exportName} /> ) : null} diff --git a/frontend/src/widgets/TotalHrsAndRecipientGraph.js b/frontend/src/widgets/TotalHrsAndRecipientGraph.js index 0f5bb3e9f4..0c18040d7a 100644 --- a/frontend/src/widgets/TotalHrsAndRecipientGraph.js +++ b/frontend/src/widgets/TotalHrsAndRecipientGraph.js @@ -1,14 +1,21 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { + useRef, + useState, + useEffect, +} from 'react'; import PropTypes from 'prop-types'; +import { TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS as TRACE_IDS } from '@ttahub/common'; import withWidgetData from './withWidgetData'; import LineGraph from './LineGraph'; import WidgetContainer from '../components/WidgetContainer'; import useMediaCapture from '../hooks/useMediaCapture'; import { arrayExistsAndHasLength, NOOP } from '../Constants'; +const EXPORT_NAME = 'Total TTA hours'; + export function TotalHrsAndRecipientGraph({ data, hideYAxis }) { const widgetRef = useRef(null); - const capture = useMediaCapture(widgetRef, 'Total TTA hours'); + const capture = useMediaCapture(widgetRef, EXPORT_NAME); const [showTabularData, setShowTabularData] = useState(false); const [columnHeadings, setColumnHeadings] = useState([]); @@ -66,13 +73,13 @@ export function TotalHrsAndRecipientGraph({ data, hideYAxis }) { yAxisTitle="Number of hours" legendConfig={[ { - label: 'Technical Assistance', selected: true, shape: 'circle', id: 'show-ta-checkbox', + label: 'Technical Assistance', selected: true, shape: 'circle', id: 'show-ta-checkbox', traceId: TRACE_IDS.TECHNICAL_ASSISTANCE, }, { - label: 'Training', selected: true, shape: 'square', id: 'show-training-checkbox', + label: 'Training', selected: true, shape: 'square', id: 'show-training-checkbox', traceId: TRACE_IDS.TRAINING, }, { - label: 'Both', selected: true, shape: 'triangle', id: 'show-both-checkbox', + label: 'Both', selected: true, shape: 'triangle', id: 'show-both-checkbox', traceId: TRACE_IDS.BOTH, }, ]} tableConfig={{ diff --git a/frontend/src/widgets/__tests__/LineGraph.js b/frontend/src/widgets/__tests__/LineGraph.js index 2582cc49af..5f83692f20 100644 --- a/frontend/src/widgets/__tests__/LineGraph.js +++ b/frontend/src/widgets/__tests__/LineGraph.js @@ -6,6 +6,7 @@ import { act, screen, } from '@testing-library/react'; +import { TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS } from '@ttahub/common/src/constants'; import LineGraph from '../LineGraph'; const traces = [ @@ -40,6 +41,8 @@ const traces = [ ], name: 'In person', traceOrder: 1, + trace: 'circle', + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.IN_PERSON, }, { x: [ @@ -72,6 +75,8 @@ const traces = [ ], name: 'Virtual', traceOrder: 2, + trace: 'square', + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.VIRTUAL, }, { x: [ @@ -104,6 +109,8 @@ const traces = [ ], name: 'Hybrid', traceOrder: 3, + trace: 'triangle', + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.HYBRID, }, ]; diff --git a/frontend/src/widgets/__tests__/TotalHrsAndRecipientGraph.js b/frontend/src/widgets/__tests__/TotalHrsAndRecipientGraph.js index 5054f47762..cd0838a64b 100644 --- a/frontend/src/widgets/__tests__/TotalHrsAndRecipientGraph.js +++ b/frontend/src/widgets/__tests__/TotalHrsAndRecipientGraph.js @@ -8,36 +8,31 @@ import { screen, act, } from '@testing-library/react'; +import { TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS } from '@ttahub/common/src/constants'; import LegendControl from '../LegendControl'; import { TotalHrsAndRecipientGraph } from '../TotalHrsAndRecipientGraph'; const TEST_DATA_MONTHS = [ { - name: 'Recipient Rec TTA', x: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], y: [1, 2, 3, 4, 5, 6], month: [false, false, false, false, false, false], + name: 'Hours of Training', x: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], y: [7, 8, 9, 0, 0, 0], month: [false, false, false, false, false, false], trace: 'square', id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.TRAINING, }, { - name: 'Hours of Training', x: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], y: [7, 8, 9, 0, 0, 0], month: [false, false, false, false, false, false], + name: 'Hours of Technical Assistance', x: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], y: [0, 0, 0, 10, 11.2348732847, 12], month: [false, false, false, false, false, false], trace: 'circle', id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.TECHNICAL_ASSISTANCE, }, { - name: 'Hours of Technical Assistance', x: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], y: [0, 0, 0, 10, 11.2348732847, 12], month: [false, false, false, false, false, false], - }, - { - name: 'Hours of Both', x: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], y: [0, 13, 0, 14, 0, 0], month: [false, false, false, false, false, false], + name: 'Hours of Both', x: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], y: [0, 13, 0, 14, 0, 0], month: [false, false, false, false, false, false], trace: 'triangle', id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.BOTH, }, ]; const TEST_DATA_DAYS = [ { - name: 'Recipient Rec TTA', x: ['1', '2', '3', '4'], y: [1, 2, 3, 4], month: ['Jan', 'Jan', 'Jan', 'Feb'], - }, - { - name: 'Hours of Training', x: ['1', '2', '3', '4'], y: [5, 6, 7, 0], month: ['Jan', 'Jan', 'Jan', 'Feb'], + name: 'Hours of Training', x: ['1', '2', '3', '4'], y: [5, 6, 7, 0], month: ['Jan', 'Jan', 'Jan', 'Feb'], trace: 'square', id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.TRAINING, }, { - name: 'Hours of Technical Assistance', x: ['1', '2', '3', '4'], y: [8, 9, 0, 0], month: ['Jan', 'Jan', 'Jan', 'Feb'], + name: 'Hours of Technical Assistance', x: ['1', '2', '3', '4'], y: [8, 9, 0, 0], month: ['Jan', 'Jan', 'Jan', 'Feb'], trace: 'circle', id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.TECHNICAL_ASSISTANCE, }, { - name: 'Hours of Both', x: ['1', '2', '3', '4'], y: [10, 0, 0, 0], month: ['Jan', 'Jan', 'Jan', 'Feb'], + name: 'Hours of Both', x: ['1', '2', '3', '4'], y: [10, 0, 0, 0], month: ['Jan', 'Jan', 'Jan', 'Feb'], trace: 'triangle', id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.BOTH, }, ]; @@ -87,11 +82,32 @@ describe('Total Hrs And Recipient Graph Widget', () => { it('expertly handles large datasets', async () => { const largeDataSet = [{ - name: 'Hours of Training', x: ['Sep-20', 'Oct-20', 'Nov-20', 'Dec-20', 'Jan-21', 'Feb-21', 'Mar-21', 'Apr-21', 'May-21', 'Jun-21', 'Jul-21', 'Aug-21', 'Sep-21', 'Oct-21', 'Nov-21', 'Dec-21', 'Jan-22', 'Feb-22', 'Mar-22', 'Apr-22', 'May-22', 'Jun-22', 'Jul-22', 'Aug-22', 'Sep-22'], y: [87.5, 209, 406.50000000000006, 439.4, 499.40000000000003, 493.6, 443.5, 555, 527.5, 428.5, 295, 493.5, 533.5, 680.5, 694, 278, 440, 611, 761.5, 534, 495.5, 551, 338.5, 772, 211], month: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], + name: 'Hours of Training', + x: ['Sep-20', 'Oct-20', 'Nov-20', 'Dec-20', 'Jan-21', 'Feb-21', 'Mar-21', 'Apr-21', 'May-21', 'Jun-21', 'Jul-21', 'Aug-21', 'Sep-21', 'Oct-21', 'Nov-21', 'Dec-21', 'Jan-22', 'Feb-22', 'Mar-22', 'Apr-22', 'May-22', 'Jun-22', 'Jul-22', 'Aug-22', 'Sep-22'], + // eslint-disable-next-line max-len + y: [87.5, 209, 406.50000000000006, 439.4, 499.40000000000003, 493.6, 443.5, 555, 527.5, 428.5, 295, 493.5, 533.5, 680.5, 694, 278, 440, 611, 761.5, 534, 495.5, 551, 338.5, 772, 211], + // eslint-disable-next-line max-len + month: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.TRAINING, + trace: 'circle', }, { - name: 'Hours of Technical Assistance', x: ['Sep-20', 'Oct-20', 'Nov-20', 'Dec-20', 'Jan-21', 'Feb-21', 'Mar-21', 'Apr-21', 'May-21', 'Jun-21', 'Jul-21', 'Aug-21', 'Sep-21', 'Oct-21', 'Nov-21', 'Dec-21', 'Jan-22', 'Feb-22', 'Mar-22', 'Apr-22', 'May-22', 'Jun-22', 'Jul-22', 'Aug-22', 'Sep-22'], y: [509.90000000000003, 1141.8999999999994, 1199.3999999999996, 1109.6999999999998, 1302.3999999999996, 1265.3999999999996, 1404.6, 1328, 1257.5, 1170, 1069.5, 1178, 1215.5, 1426.5, 1219.5, 1063, 1151, 1316, 1436, 1400, 1518.5, 1353, 1238, 1202, 578], month: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], + name: 'Hours of Technical Assistance', + x: ['Sep-20', 'Oct-20', 'Nov-20', 'Dec-20', 'Jan-21', 'Feb-21', 'Mar-21', 'Apr-21', 'May-21', 'Jun-21', 'Jul-21', 'Aug-21', 'Sep-21', 'Oct-21', 'Nov-21', 'Dec-21', 'Jan-22', 'Feb-22', 'Mar-22', 'Apr-22', 'May-22', 'Jun-22', 'Jul-22', 'Aug-22', 'Sep-22'], + // eslint-disable-next-line max-len + y: [509.90000000000003, 1141.8999999999994, 1199.3999999999996, 1109.6999999999998, 1302.3999999999996, 1265.3999999999996, 1404.6, 1328, 1257.5, 1170, 1069.5, 1178, 1215.5, 1426.5, 1219.5, 1063, 1151, 1316, 1436, 1400, 1518.5, 1353, 1238, 1202, 578], + // eslint-disable-next-line max-len + month: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.TECHNICAL_ASSISTANCE, + trace: 'square', }, { - name: 'Hours of Both', x: ['Sep-20', 'Oct-20', 'Nov-20', 'Dec-20', 'Jan-21', 'Feb-21', 'Mar-21', 'Apr-21', 'May-21', 'Jun-21', 'Jul-21', 'Aug-21', 'Sep-21', 'Oct-21', 'Nov-21', 'Dec-21', 'Jan-22', 'Feb-22', 'Mar-22', 'Apr-22', 'May-22', 'Jun-22', 'Jul-22', 'Aug-22', 'Sep-22'], y: [55, 134.5, 173, 137.5, 190, 248.8, 234.3, 230, 193.5, 187.5, 200.5, 202.5, 224.5, 299.5, 155, 206.5, 209.5, 251.5, 234, 206, 235.5, 245, 279.5, 274.5, 155.5], month: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], + name: 'Hours of Both', + x: ['Sep-20', 'Oct-20', 'Nov-20', 'Dec-20', 'Jan-21', 'Feb-21', 'Mar-21', 'Apr-21', 'May-21', 'Jun-21', 'Jul-21', 'Aug-21', 'Sep-21', 'Oct-21', 'Nov-21', 'Dec-21', 'Jan-22', 'Feb-22', 'Mar-22', 'Apr-22', 'May-22', 'Jun-22', 'Jul-22', 'Aug-22', 'Sep-22'], + // eslint-disable-next-line max-len + y: [55, 134.5, 173, 137.5, 190, 248.8, 234.3, 230, 193.5, 187.5, 200.5, 202.5, 224.5, 299.5, 155, 206.5, 209.5, 251.5, 234, 206, 235.5, 245, 279.5, 274.5, 155.5], + // eslint-disable-next-line max-len + month: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.BOTH, + trace: 'triangle', }]; act(() => { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 23e527db88..36f4a34d62 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2431,10 +2431,10 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== -"@ttahub/common@^2.1.5": - version "2.1.6" - resolved "https://registry.yarnpkg.com/@ttahub/common/-/common-2.1.6.tgz#259d98201d394eafce7686f8334768091af4655d" - integrity sha512-/X/suR8B5aKYuVXXRHa1gjBTMzzz7vyXDCwATkZ4McQhoil8dtzndYgACDFY5bC+ZsEIfqiTcDQ+Ssle1N9mbA== +"@ttahub/common@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@ttahub/common/-/common-2.1.7.tgz#739668720f08874b04ec21a428e7453737a5cfb3" + integrity sha512-LNV8DmklA2jwztAF8KOcK3/SFdJNzWCn+o6QquMxGztN8YIzsDoxik9zoygCVtVQwUQo7Y5XXPA9h3fwkUHjag== "@turf/area@^6.4.0": version "6.5.0" @@ -4350,9 +4350,9 @@ cross-fetch@^3.0.4, cross-fetch@^3.1.5: node-fetch "2.6.7" cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" diff --git a/manifest.yml b/manifest.yml index b75f8e2f4e..6f9eb41754 100644 --- a/manifest.yml +++ b/manifest.yml @@ -38,6 +38,10 @@ applications: SIMILARITY_ENDPOINT: ((SIMILARITY_ENDPOINT)) SMARTSHEET_ENDPOINT: ((SMARTSHEET_ENDPOINT)) SMARTSHEET_ACCESS_TOKEN: ((SMARTSHEET_ACCESS_TOKEN)) + BUILD_BRANCH: ((BUILD_BRANCH)) + BUILD_COMMIT: ((BUILD_COMMIT)) + BUILD_NUMBER: ((BUILD_NUMBER)) + BUILD_TIMESTAMP: ((BUILD_TIMESTAMP)) services: - ttahub-((env)) - ttahub-redis-((env)) diff --git a/package.json b/package.json index a6b437a0b0..8ddf6cf934 100644 --- a/package.json +++ b/package.json @@ -52,31 +52,32 @@ "db:seed:prod": "node_modules/.bin/sequelize db:seed:all --options-path .production.sequelizerc", "db:seed:undo": "node_modules/.bin/sequelize db:seed:undo:all", "db:seed:undo:prod": "node_modules/.bin/sequelize db:seed:undo:all --options-path .production.sequelizerc", - "docker:deps": "docker compose run --rm backend yarn install && docker compose run --rm frontend yarn install && docker compose run --rm worker yarn install", + "docker:clear": "docker images | grep head-start | awk '{print $3}' | while read x; do docker image rm -f $x; done", + "docker:deps": "docker compose --profile minimal_required_postgres --profile minimal_required_redis run --rm backend yarn install && docker compose --profile minimal_required_postgres --profile minimal_required_redis run --rm frontend yarn install && docker compose --profile minimal_required_postgres --profile minimal_required_redis run --rm worker yarn install", "docker:reset": "./bin/reset-all", - "docker:start": "docker compose --profile minimal_required up", - "docker:start:full": "docker compose --profile full_stack --profile minimal_required up", - "docker:stop:full": "docker compose --profile full_stack --profile minimal_required down", + "docker:start": "docker compose --profile minimal_required_node --profile minimal_required_postgres --profile minimal_required_redis --profile minimal_required_python up", + "docker:start:native": "docker compose --profile full_stack --profile minimal_required_postgres --profile minimal_required_redis --profile minimal_required_python up & sleep 1 && docker compose -f docker-compose.yml stop db", + "docker:start:full": "docker compose --profile full_stack --profile full_stack_zap --profile minimal_required_node --profile minimal_required_postgres --profile minimal_required_redis --profile minimal_required_python up", "docker:start:debug": "docker compose --compatibility -f docker-compose.yml -f docker-compose.debug.yml up", - "docker:stop": "docker compose --profile minimal_required down", - "docker:dbs:start": "docker compose -f 'docker-compose.yml' up", - "docker:dbs:stop": "docker compose -f 'docker-compose.yml' down", + "docker:stop": "docker compose --profile full_stack --profile full_stack_zap --profile minimal_required_node --profile minimal_required_postgres --profile minimal_required_redis --profile minimal_required_python down", + "docker:dbs:start": "docker compose -f 'docker-compose.yml' --profile full_stack --profile minimal_required_redis --profile minimal_required_python up", + "docker:dbs:stop": "yarn docker:stop", "docker:test": "./bin/run-tests", - "docker:test:be": "docker compose run --rm backend yarn test", - "docker:lint": "docker compose run --rm backend yarn lint:ci && docker compose run --rm frontend yarn lint:ci", - "docker:lint:fix": "docker compose run --rm backend yarn lint:fix && docker compose run --rm frontend yarn lint:fix", + "docker:test:be": "yarn docker:yarn:be test", + "docker:lint": "yarn docker:yarn:be lint:ci && yarn docker:yarn:fe lint:ci", + "docker:lint:fix": "yarn docker:yarn:be lint:fix && yarn docker:yarn:fe lint:fix", "docker:shell:frontend": "docker compose run --rm frontend /bin/bash", - "docker:shell:backend": "docker compose run --rm backend /bin/bash", - "docker:db:migrate": "docker compose run --rm backend node_modules/.bin/sequelize db:migrate && yarn docker:ldm", - "docker:db:migrate:undo": "docker compose run --rm backend node_modules/.bin/sequelize db:migrate:undo", - "docker:db:seed": "docker compose run --rm backend yarn db:seed", - "docker:db:seed:undo": "docker compose run --rm backend yarn db:seed:undo", - "docker:import:system": "docker compose run --rm backend yarn import:system", - "docker:ldm": "docker compose run --rm backend yarn ldm", - "docker:makecolors": "docker compose run --rm frontend yarn makecolors", + "docker:shell:backend": "yarn docker:yarn:be /bin/bash", + "docker:db:migrate": "yarn docker:yarn:be node_modules/.bin/sequelize db:migrate && yarn docker:ldm", + "docker:db:migrate:undo": "yarn docker:yarn:be node_modules/.bin/sequelize db:migrate:undo", + "docker:db:seed": "yarn docker:yarn:be db:seed", + "docker:db:seed:undo": "yarn docker:yarn:be db:seed:undo", + "docker:import:system": "yarn docker:yarn:be import:system", + "docker:ldm": "yarn docker:yarn:be ldm", + "docker:makecolors": "yarn docker:yarn:fe makecolors", "docker:yarn": "docker compose run yarn", - "docker:yarn:fe": "docker compose run --rm frontend yarn", - "docker:yarn:be": "docker compose run --rm backend yarn", + "docker:yarn:fe": "docker compose run --rm frontend yarn", + "docker:yarn:be": "docker compose --profile minimal_required_postgres --profile minimal_required_redis run --rm backend yarn", "import:reports:local": "./node_modules/.bin/babel-node ./src/tools/importSSActivityReports.js", "import:reports": "node ./build/server/src/tools/importSSActivityReports.js", "import:goals:local": "./node_modules/.bin/babel-node ./src/tools/importTTAPlanGoals.js", @@ -88,7 +89,7 @@ "import:system": "cross-env POSTGRES_USERNAME=postgres POSTGRES_DB=ttasmarthub tsx src/tools/importSystemCLI.ts", "reconcile:legacy": "node ./build/server/src/tools/reconcileLegacyReports.js", "reconcile:legacy:local": "./node_modules/.bin/babel-node ./src/tools/reconcileLegacyReports.js", - "processData:local": "./node_modules/.bin/babel-node ./src/tools/processDataCLI.js", + "processData:local": "tsx ./src/tools/processDataCLI.js", "ldm:ci": "cross-env POSTGRES_USERNAME=postgres POSTGRES_DB=ttasmarthub tsx ./src/tools/logicalDataModelCLI.ts", "ldm": "tsx ./src/tools/logicalDataModelCLI.ts", "changeReportStatus": "node ./build/server/src/tools/changeReportStatusCLI.js", @@ -97,7 +98,7 @@ "restoreTopics": "node ./build/server/src/tools/restoreTopicsCLI.js", "restoreTopics:local": "./node_modules/.bin/babel-node ./src/tools/restoreTopicsCLI.js", "coverage:backend": "./bin/build-coverage-report", - "docker:coverage:backend": "docker compose run --rm backend chmod 744 ./bin/build-coverage-report && ./bin/build-coverage-report", + "docker:coverage:backend": "docker compose --profile minimal_required_postgres --profile minimal_required_redis run --rm backend chmod 744 ./bin/build-coverage-report && ./bin/build-coverage-report", "populateLegacyResourceTitles": "node ./build/server/src/tools/populateLegacyResourceTitlesCli.js", "populateLegacyResourceTitles:local": "./node_modules/.bin/babel-node ./src/tools/populateLegacyResourceTitlesCli.js", "updateCompletedEventReportPilots": "node ./build/server/src/tools/updateCompletedEventReportPilotsCLI.js", @@ -309,7 +310,6 @@ "puppeteer-select": "^1.0.3", "redoc-cli": "^0.13.2", "selenium-webdriver": "4.3.0", - "simple-git": "^3.19.1", "sinon": "^15.0.0", "supertest": "^6.1.3", "tsx": "^3.12.2", @@ -320,7 +320,7 @@ "@babel/runtime": "^7.12.1", "@faker-js/faker": "^6.0.0", "@opensearch-project/opensearch": "^1.1.0", - "@ttahub/common": "^2.1.6", + "@ttahub/common": "^2.1.7", "adm-zip": "^0.5.1", "aws-sdk": "^2.826.0", "aws4": "^1.11.0", @@ -368,6 +368,7 @@ "sax": "^1.3.0", "sequelize": "^6.29.0", "sequelize-cli": "^6.2.0", + "simple-git": "3.19.1", "smartsheet": "^4.0.2", "ssh2": "^1.15.0", "throng": "^5.0.0", diff --git a/packages/common/package.json b/packages/common/package.json index a4be178c4a..ecac3555f1 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@ttahub/common", - "version": "2.1.6", + "version": "2.1.7", "description": "The purpose of this package is to reduce code duplication between the frontend and backend projects.", "main": "src/index.js", "author": "", diff --git a/packages/common/src/constants.js b/packages/common/src/constants.js index cedb48745b..d455f5c4cf 100644 --- a/packages/common/src/constants.js +++ b/packages/common/src/constants.js @@ -381,3 +381,9 @@ exports.DISALLOWED_URLS = DISALLOWED_URLS; const VALID_URL_REGEX = /(?(?http(?:s)?):\/\/(?:(?[a-zA-Z0-9._]+)(?:[:](?[a-zA-Z0-9%._\+~#=]+))?[@])?(?:(?:www\.)?(?[-a-zA-Z0-9%._\+~#=]{1,}\.[a-z]{2,6})|(?(?:[0-9]{1,3}\.){3}[0-9]{1,3}))(?:[:](?[0-9]+))?(?:[\/](?[-a-zA-Z0-9'@:%_\+.,~#&\/=()]*[-a-zA-Z0-9@:%_\+.~#&\/=()])?)?(?:[?](?[-a-zA-Z0-9@:%_\+.~#&\/=()]*))*)/ig; exports.VALID_URL_REGEX = VALID_URL_REGEX; +const TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS = { + TRAINING: 'training', + TECHNICAL_ASSISTANCE: 'technical-assistance', + BOTH: 'both', +}; + exports.TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS = TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS; diff --git a/src/goalServices/goals.js b/src/goalServices/goals.js index 5dca1120bd..cf926ed58e 100644 --- a/src/goalServices/goals.js +++ b/src/goalServices/goals.js @@ -784,7 +784,11 @@ export async function goalsForGrants(grantIds) { name: { [Op.ne]: '', // exclude "blank" goals }, - '$grant.id$': ids, + [Op.or]: { + '$grant.id$': ids, + '$grant.grantRelationships.grantId$': ids, + '$grant.grantRelationships.activeGrantId$': ids, + }, status: { [Op.notIn]: ['Closed', 'Suspended'], }, diff --git a/src/goalServices/goals.test.js b/src/goalServices/goals.test.js index 32a4a3c936..57b9e7f9da 100644 --- a/src/goalServices/goals.test.js +++ b/src/goalServices/goals.test.js @@ -1445,10 +1445,11 @@ describe('Goals DB service', () => { await goalsForGrants([506]); const { where } = Goal.findAll.mock.calls[0][0]; - expect(where['$grant.id$']).toStrictEqual([ - 505, - 506, - ]); + expect(where[Op.or]).toMatchObject({ + '$grant.id$': [505, 506], + '$grant.grantRelationships.grantId$': [505, 506], + '$grant.grantRelationships.activeGrantId$': [505, 506], + }); }); }); diff --git a/src/lib/cron.js b/src/lib/cron.js index 5ce5c379f9..75391da763 100644 --- a/src/lib/cron.js +++ b/src/lib/cron.js @@ -44,7 +44,9 @@ const runDailyEmailJob = () => { await submittedDigest(EMAIL_DIGEST_FREQ.DAILY, DIGEST_SUBJECT_FREQ.DAILY); await approvedDigest(EMAIL_DIGEST_FREQ.DAILY, DIGEST_SUBJECT_FREQ.DAILY); await recipientApprovedDigest(EMAIL_DIGEST_FREQ.DAILY, DIGEST_SUBJECT_FREQ.DAILY); - await trainingReportTaskDueNotifications(EMAIL_DIGEST_FREQ.DAILY); + if (process.env.SEND_TRAININGREPORTTASKDUENOTIFICATION === 'true') { + await trainingReportTaskDueNotifications(EMAIL_DIGEST_FREQ.DAILY); + } } catch (error) { auditLogger.error(`Error processing Daily Email Digest job: ${error}`); logger.error(`Daily Email Digest Error: ${error}`); diff --git a/src/lib/queue.js b/src/lib/queue.js index b2d08869c4..cd1b739702 100644 --- a/src/lib/queue.js +++ b/src/lib/queue.js @@ -4,54 +4,71 @@ import { auditLogger } from '../logger'; const generateRedisConfig = (enableRateLimiter = false) => { if (process.env.VCAP_SERVICES) { - const { - 'aws-elasticache-redis': [{ + const services = JSON.parse(process.env.VCAP_SERVICES); + + // Check if the 'aws-elasticache-redis' service is available in VCAP_SERVICES + if (services['aws-elasticache-redis'] && services['aws-elasticache-redis'].length > 0) { + const { credentials: { host, port, password, uri, }, - }], - } = JSON.parse(process.env.VCAP_SERVICES); - - let redisSettings = { - uri, - host, - port, - tlsEnabled: true, - // TLS needs to be set to an empty object for redis on cloud.gov - // eslint-disable-next-line no-empty-pattern - redisOpts: { - redis: { password, tls: {} }, - }, - }; - - // Explicitly set the rate limiter settings. - if (enableRateLimiter) { - redisSettings = { - ...redisSettings, + } = services['aws-elasticache-redis'][0]; + + let redisSettings = { + uri, + host, + port, + tlsEnabled: true, + // TLS needs to be set to an empty object for redis on cloud.gov + // eslint-disable-next-line no-empty-pattern redisOpts: { - ...redisSettings.redisOpts, - limiter: { - max: process.env.REDIS_LIMITER_MAX || 1000, - duration: process.env.REDIS_LIMITER_DURATION || 300000, - }, + redis: { password, tls: {} }, }, }; + + // Explicitly set the rate limiter settings. + if (enableRateLimiter) { + redisSettings = { + ...redisSettings, + redisOpts: { + ...redisSettings.redisOpts, + limiter: { + max: process.env.REDIS_LIMITER_MAX || 1000, + duration: process.env.REDIS_LIMITER_DURATION || 300000, + }, + }, + }; + } + + return redisSettings; } + } - return redisSettings; + // Check for the presence of Redis-related environment variables + const { REDIS_HOST, REDIS_PASS, REDIS_PORT } = process.env; + + if (REDIS_HOST && REDIS_PASS) { + return { + host: REDIS_HOST, + uri: `redis://:${REDIS_PASS}@${REDIS_HOST}:${REDIS_PORT || 6379}`, + port: REDIS_PORT || 6379, + tlsEnabled: false, + redisOpts: { + redis: { password: REDIS_PASS }, + }, + }; } - const { REDIS_HOST: host, REDIS_PASS: password } = process.env; + + // Return a minimal configuration if Redis is not configured return { - host, - uri: `redis://:${password}@${host}:${process.env.REDIS_PORT || 6379}`, - port: (process.env.REDIS_PORT || 6379), + host: null, + uri: null, + port: null, tlsEnabled: false, - redisOpts: { - redis: { password }, - }, + redisOpts: {}, }; }; diff --git a/src/lib/s3.js b/src/lib/s3.js index 44b3bfe2db..6e06c7b634 100644 --- a/src/lib/s3.js +++ b/src/lib/s3.js @@ -2,37 +2,62 @@ import { S3 } from 'aws-sdk'; import { auditLogger } from '../logger'; const generateS3Config = () => { - // take configuration from cloud.gov if it is available. If not, use env variables. + // Take configuration from cloud.gov if it is available. If not, use env variables. if (process.env.VCAP_SERVICES) { - const { credentials } = JSON.parse(process.env.VCAP_SERVICES).s3[0]; + const services = JSON.parse(process.env.VCAP_SERVICES); + + // Check if the s3 service is available in VCAP_SERVICES + if (services.s3 && services.s3.length > 0) { + const { credentials } = services.s3[0]; + return { + bucketName: credentials.bucket, + s3Config: { + accessKeyId: credentials.access_key_id, + endpoint: credentials.fips_endpoint, + region: credentials.region, + secretAccessKey: credentials.secret_access_key, + signatureVersion: 'v4', + s3ForcePathStyle: true, + }, + }; + } + } + + // Check for the presence of S3-related environment variables + const { + S3_BUCKET, + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + S3_ENDPOINT, + } = process.env; + + if (S3_BUCKET && AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY) { return { - bucketName: credentials.bucket, + bucketName: S3_BUCKET, s3Config: { - accessKeyId: credentials.access_key_id, - endpoint: credentials.fips_endpoint, - region: credentials.region, - secretAccessKey: credentials.secret_access_key, + accessKeyId: AWS_ACCESS_KEY_ID, + endpoint: S3_ENDPOINT, + secretAccessKey: AWS_SECRET_ACCESS_KEY, signatureVersion: 'v4', s3ForcePathStyle: true, }, }; } + + // Return null if S3 is not configured return { - bucketName: process.env.S3_BUCKET, - s3Config: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - endpoint: process.env.S3_ENDPOINT, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - signatureVersion: 'v4', - s3ForcePathStyle: true, - }, + bucketName: null, + s3Config: null, }; }; const { bucketName, s3Config } = generateS3Config(); -const s3 = new S3(s3Config); +const s3 = s3Config ? new S3(s3Config) : null; const deleteFileFromS3 = async (key, bucket = bucketName, s3Client = s3) => { + if (!s3Client || !bucket) { + throw new Error('S3 is not configured.'); + } const params = { Bucket: bucket, Key: key, @@ -40,13 +65,13 @@ const deleteFileFromS3 = async (key, bucket = bucketName, s3Client = s3) => { return s3Client.deleteObject(params).promise(); }; -const deleteFileFromS3Job = async (job) => { +const deleteFileFromS3Job = async (job, s3Client = s3) => { const { fileId, fileKey, bucket, } = job.data; let res; try { - res = await deleteFileFromS3(fileKey, bucket); + res = await deleteFileFromS3(fileKey, bucket, s3Client); return ({ status: 200, data: { fileId, fileKey, res } }); } catch (error) { auditLogger.error(`S3 Queue Error: Unable to DELETE file '${fileId}' for key '${fileKey}': ${error.message}`); @@ -55,6 +80,9 @@ const deleteFileFromS3Job = async (job) => { }; const verifyVersioning = async (bucket = bucketName, s3Client = s3) => { + if (!s3Client || !bucket) { + throw new Error('S3 is not configured.'); + } const versioningConfiguration = { MFADelete: 'Disabled', Status: 'Enabled', @@ -73,16 +101,23 @@ const verifyVersioning = async (bucket = bucketName, s3Client = s3) => { return data; }; -const downloadFile = (key) => { +const downloadFile = (key, s3Client = s3, Bucket = bucketName) => { + if (!s3Client || !Bucket) { + throw new Error('S3 is not configured.'); + } const params = { - Bucket: bucketName, + Bucket, Key: key, }; - return s3.getObject(params).promise(); + return s3Client.getObject(params).promise(); }; const getPresignedURL = (Key, Bucket = bucketName, s3Client = s3, Expires = 360) => { const url = { url: null, error: null }; + if (!s3Client || !Bucket) { + url.error = new Error('S3 is not configured.'); + return url; + } try { const params = { Bucket, @@ -96,16 +131,19 @@ const getPresignedURL = (Key, Bucket = bucketName, s3Client = s3, Expires = 360) return url; }; -const uploadFile = async (buffer, name, type, s3Client = s3) => { +const uploadFile = async (buffer, name, type, s3Client = s3, Bucket = bucketName) => { + if (!s3Client || !Bucket) { + throw new Error('S3 is not configured.'); + } const params = { Body: buffer, - Bucket: bucketName, + Bucket, ContentType: type.mime, Key: name, }; // Only check for versioning if not using Minio if (process.env.NODE_ENV === 'production') { - await verifyVersioning(); + await verifyVersioning(Bucket, s3Client); } return s3Client.upload(params).promise(); diff --git a/src/lib/s3.test.js b/src/lib/s3.test.js index 1d309ca32f..9a1fe582cd 100644 --- a/src/lib/s3.test.js +++ b/src/lib/s3.test.js @@ -1,6 +1,8 @@ import { v4 as uuidv4 } from 'uuid'; +import { S3 } from 'aws-sdk'; import { s3, + downloadFile, verifyVersioning, uploadFile, getPresignedURL, @@ -9,6 +11,21 @@ import { deleteFileFromS3Job, } from './s3'; +jest.mock('aws-sdk', () => { + const mS3 = { + getBucketVersioning: jest.fn(), + putBucketVersioning: jest.fn(), + upload: jest.fn(), + getSignedUrl: jest.fn(), + deleteObject: jest.fn(), + getObject: jest.fn().mockReturnThis(), + promise: jest.fn(), + }; + return { S3: jest.fn(() => mS3) }; +}); + +const mockS3 = /* s3 || */ S3(); + const oldEnv = { ...process.env }; const VCAP_SERVICES = { s3: [ @@ -38,173 +55,320 @@ const VCAP_SERVICES = { }, ], }; +describe('S3', () => { + describe('Tests s3 client setup', () => { + afterEach(() => { process.env = oldEnv; }); -describe('Tests s3 client setup', () => { - afterEach(() => { process.env = oldEnv; }); - it('returns proper config with process.env.VCAP_SERVICES set', () => { - process.env.VCAP_SERVICES = JSON.stringify(VCAP_SERVICES); - const { credentials } = VCAP_SERVICES.s3[0]; - const want = { - bucketName: credentials.bucket, - s3Config: { - accessKeyId: credentials.access_key_id, - endpoint: credentials.fips_endpoint, - secretAccessKey: credentials.secret_access_key, - signatureVersion: 'v4', - s3ForcePathStyle: true, - }, - }; - const got = generateS3Config(); - expect(got).toMatchObject(want); - }); - it('returns proper config with process.env.VCAP_SERVICES not set', () => { - process.env.S3_BUCKET = 'test-bucket'; - process.env.AWS_ACCESS_KEY_ID = 'superSecretAccessKeyId'; - process.env.AWS_SECRET_ACCESS_KEY = 'superSecretAccessKey'; - process.env.S3_ENDPOINT = 'localhost'; - - const want = { - bucketName: process.env.S3_BUCKET, - s3Config: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - endpoint: process.env.S3_ENDPOINT, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - signatureVersion: 'v4', - s3ForcePathStyle: true, - }, - }; - const got = generateS3Config(); - expect(got).toMatchObject(want); - }); -}); + it('returns proper config with process.env.VCAP_SERVICES set', () => { + process.env.VCAP_SERVICES = JSON.stringify(VCAP_SERVICES); + const { credentials } = VCAP_SERVICES.s3[0]; + const want = { + bucketName: credentials.bucket, + s3Config: { + accessKeyId: credentials.access_key_id, + endpoint: credentials.fips_endpoint, + secretAccessKey: credentials.secret_access_key, + signatureVersion: 'v4', + s3ForcePathStyle: true, + }, + }; + const got = generateS3Config(); + expect(got).toMatchObject(want); + }); -const mockVersioningData = { - MFADelete: 'Disabled', - Status: 'Enabled', -}; + it('returns proper config with process.env.VCAP_SERVICES not set', () => { + process.env.S3_BUCKET = 'ttadp-test'; + process.env.AWS_ACCESS_KEY_ID = 'superSecretAccessKeyId'; + process.env.AWS_SECRET_ACCESS_KEY = 'superSecretAccessKey'; + process.env.S3_ENDPOINT = 'localhost'; -describe('verifyVersioning', () => { - let mockGet = jest.spyOn(s3, 'getBucketVersioning').mockImplementation(async () => mockVersioningData); - const mockPut = jest.spyOn(s3, 'putBucketVersioning').mockImplementation(async (params) => new Promise((res) => { res(params); })); - beforeEach(() => { - mockGet.mockClear(); - mockPut.mockClear(); - }); - it('Doesn\'t change things if versioning is enabled', async () => { - const got = await verifyVersioning(); - expect(mockGet.mock.calls.length).toBe(1); - expect(mockPut.mock.calls.length).toBe(0); - expect(got).toBe(mockVersioningData); - }); - it('Enables versioning if it is disabled', async () => { - mockGet = jest.spyOn(s3, 'getBucketVersioning').mockImplementationOnce(async () => { }); - const got = await verifyVersioning(process.env.S3_BUCKET); - expect(mockGet.mock.calls.length).toBe(1); - expect(mockPut.mock.calls.length).toBe(1); - expect(got.Bucket).toBe(process.env.S3_BUCKET); - expect(got.VersioningConfiguration.MFADelete).toBe(mockVersioningData.MFADelete); - expect(got.VersioningConfiguration.Status).toBe(mockVersioningData.Status); + const want = { + bucketName: process.env.S3_BUCKET, + s3Config: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + endpoint: process.env.S3_ENDPOINT, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + signatureVersion: 'v4', + s3ForcePathStyle: true, + }, + }; + const got = generateS3Config(); + expect(got).toMatchObject(want); + }); + + it('returns null config when no S3 environment variables or VCAP_SERVICES are set', () => { + const oldVCAP = process.env.VCAP_SERVICES; + const oldBucket = process.env.S3_BUCKET; + const oldAccessKey = process.env.AWS_ACCESS_KEY_ID; + const oldSecretKey = process.env.AWS_SECRET_ACCESS_KEY; + const oldEndpoint = process.env.S3_ENDPOINT; + + delete process.env.VCAP_SERVICES; + delete process.env.S3_BUCKET; + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.S3_ENDPOINT; + + const want = { + bucketName: null, + s3Config: null, + }; + const got = generateS3Config(); + expect(got).toMatchObject(want); + + process.env.VCAP_SERVICES = oldVCAP; + process.env.S3_BUCKET = oldBucket; + process.env.AWS_ACCESS_KEY_ID = oldAccessKey; + process.env.AWS_SECRET_ACCESS_KEY = oldSecretKey; + process.env.S3_ENDPOINT = oldEndpoint; + }); }); -}); -describe('uploadFile', () => { - const goodType = { ext: 'pdf', mime: 'application/pdf' }; - const buf = Buffer.from('Testing, Testing', 'UTF-8'); - const name = `${uuidv4()}.${goodType.ext}`; - const response = { - ETag: '"8b03d1d48774bfafdb26691256fc7b2b"', - Location: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET}/${name}`, - key: `${name}`, - Key: `${name}`, - Bucket: `${process.env.S3_BUCKET}`, - }; - const promise = { - promise: () => new Promise((resolve) => { resolve(response); }), + const mockVersioningData = { + MFADelete: 'Disabled', + Status: 'Enabled', }; - const mockUpload = jest.spyOn(s3, 'upload').mockImplementation(() => promise); - const mockGet = jest.spyOn(s3, 'getBucketVersioning').mockImplementation(async () => mockVersioningData); - beforeEach(() => { - mockUpload.mockClear(); - mockGet.mockClear(); - }); - afterAll(() => { - process.env = oldEnv; - }); - it('Correctly Uploads the file', async () => { - process.env.NODE_ENV = 'development'; - const got = await uploadFile(buf, name, goodType); - expect(mockGet.mock.calls.length).toBe(0); - await expect(got).toBe(response); - }); - it('Correctly Uploads the file and checks versioning', async () => { - process.env.NODE_ENV = 'production'; - const got = await uploadFile(buf, name, goodType); - expect(mockGet.mock.calls.length).toBe(1); - await expect(got).toBe(response); - }); -}); + describe('verifyVersioning', () => { + let mockGet; + let mockPut; -describe('getPresignedUrl', () => { - const Bucket = 'fakeBucket'; - const Key = 'fakeKey'; - const fakeError = new Error('fake error'); - const mockGetURL = jest.spyOn(s3, 'getSignedUrl').mockImplementation(() => 'https://example.com'); - beforeEach(() => { - mockGetURL.mockClear(); - }); - it('calls getSignedUrl() with correct parameters', () => { - const url = getPresignedURL(Key, Bucket); - expect(url).toMatchObject({ url: 'https://example.com', error: null }); - expect(mockGetURL).toHaveBeenCalled(); - expect(mockGetURL).toHaveBeenCalledWith('getObject', { Bucket, Key, Expires: 360 }); + beforeEach(() => { + mockS3.getBucketVersioning = jest.fn(); + mockS3.putBucketVersioning = jest.fn(); + mockGet = mockS3.getBucketVersioning.mockImplementation(async () => mockVersioningData); + mockPut = mockS3.putBucketVersioning.mockImplementation( + async (params) => new Promise((res) => { + res(params); + }), + ); + mockGet.mockClear(); + mockPut.mockClear(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('throws an error if S3 is not configured', async () => { + await expect(verifyVersioning(VCAP_SERVICES.s3[0].binding_name, null)).rejects.toThrow('S3 is not configured.'); + }); + + it('Doesn\'t change things if versioning is enabled', async () => { + const { bucketName } = generateS3Config(); + const got = await verifyVersioning(bucketName, mockS3); + expect(mockGet.mock.calls.length).toBe(1); + expect(mockPut.mock.calls.length).toBe(0); + expect(got).toBe(mockVersioningData); + }); + + it('Enables versioning if it is disabled', async () => { + mockGet.mockImplementationOnce(async () => { }); // Simulate disabled versioning + const got = await verifyVersioning(process.env.S3_BUCKET, mockS3); + expect(mockGet.mock.calls.length).toBe(1); + expect(mockPut.mock.calls.length).toBe(1); + expect(got.Bucket).toBe(process.env.S3_BUCKET); + expect(got.VersioningConfiguration.MFADelete).toBe(mockVersioningData.MFADelete); + expect(got.VersioningConfiguration.Status).toBe(mockVersioningData.Status); + }); }); - it('calls getSignedUrl() with incorrect parameters', async () => { - mockGetURL.mockImplementationOnce(() => { throw fakeError; }); - const url = getPresignedURL(Key, Bucket); - expect(url).toMatchObject({ url: null, error: fakeError }); - expect(mockGetURL).toHaveBeenCalled(); - expect(mockGetURL).toHaveBeenCalledWith('getObject', { Bucket, Key, Expires: 360 }); + + describe('uploadFile', () => { + const goodType = { ext: 'pdf', mime: 'application/pdf' }; + const buf = Buffer.from('Testing, Testing', 'UTF-8'); + const name = `${uuidv4()}.${goodType.ext}`; + const response = { + ETag: '"8b03d1d48774bfafdb26691256fc7b2b"', + Location: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET}/${name}`, + key: `${name}`, + Key: `${name}`, + Bucket: `${process.env.S3_BUCKET}`, + }; + const promise = { + promise: () => new Promise((resolve) => { resolve(response); }), + }; + let mockGet; + + beforeEach(() => { + mockS3.upload = jest.fn(); + mockS3.getBucketVersioning = jest.fn(); + mockS3.upload.mockImplementation(() => promise); + mockGet = mockS3.getBucketVersioning.mockImplementation(async () => mockVersioningData); + }); + + afterAll(() => { + process.env = oldEnv; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('throws an error if S3 is not configured', async () => { + await expect(uploadFile(buf, name, goodType, null)).rejects.toThrow('S3 is not configured.'); + }); + + it('Correctly Uploads the file and checks versioning', async () => { + const { bucketName } = generateS3Config(); + process.env.NODE_ENV = 'production'; + const got = await uploadFile(buf, name, goodType, mockS3, bucketName); + expect(mockGet.mock.calls.length).toBe(1); + expect(got).toBe(response); + }); }); -}); -describe('s3Uploader.deleteFileFromS3', () => { - const Bucket = 'fakeBucket'; - const Key = 'fakeKey'; - const anotherFakeError = Error('fake'); - it('calls deleteFileFromS3() with correct parameters', async () => { - const mockDeleteObject = jest.spyOn(s3, 'deleteObject').mockImplementation(() => ({ promise: () => Promise.resolve('good') })); - const got = deleteFileFromS3(Key, Bucket); - await expect(got).resolves.toBe('good'); - expect(mockDeleteObject).toHaveBeenCalledWith({ Bucket, Key }); + + describe('downloadFile', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + it('returns an error if S3 is not configured', () => { + expect(() => downloadFile(null, null)).toThrow('S3 is not configured.'); + }); + it('downloads a file successfully', async () => { + const { bucketName } = generateS3Config(); + const key = 'test-file.txt'; + // Mock the promise to resolve with some file content + mockS3.promise.mockResolvedValue({ Body: 'file-content' }); + mockS3.getObject.mockImplementation(() => mockS3); + + // Call the function + const result = await downloadFile(key, mockS3, bucketName); + + // Verify getObject was called with the right parameters + expect(mockS3.getObject).toHaveBeenCalledWith({ + Bucket: bucketName, + Key: key, + }); + + // Verify the result + expect(result).toEqual({ Body: 'file-content' }); + }); }); - it('throws an error if promise rejects', async () => { - const mockDeleteObject = jest.spyOn(s3, 'deleteObject').mockImplementationOnce( - () => ({ promise: () => Promise.reject(anotherFakeError) }), - ); - const got = deleteFileFromS3(Key); - await expect(got).rejects.toBe(anotherFakeError); - expect(mockDeleteObject).toHaveBeenCalledWith({ Bucket, Key }); + + describe('getPresignedURL', () => { + const Bucket = 'ttadp-test'; + const Key = 'fakeKey'; + const fakeError = new Error('fake error'); + let mockGetURL; + + beforeEach(() => { + mockS3.getSignedUrl = jest.fn(); + mockGetURL = mockS3.getSignedUrl.mockImplementation(() => 'https://example.com'); + }); + + it('returns an error if S3 is not configured', () => { + const url = getPresignedURL(Key, Bucket, null); + expect(url).toMatchObject({ url: null, error: new Error('S3 is not configured.') }); + }); + + it('calls getSignedUrl() with correct parameters', () => { + const url = getPresignedURL(Key, Bucket, mockS3); + expect(url).toMatchObject({ url: 'https://example.com', error: null }); + expect(mockGetURL).toHaveBeenCalled(); + expect(mockGetURL).toHaveBeenCalledWith('getObject', { Bucket, Key, Expires: 360 }); + }); + + it('calls getSignedUrl() with incorrect parameters', async () => { + mockGetURL.mockImplementationOnce(() => { throw fakeError; }); + const url = getPresignedURL(Key, Bucket, mockS3); + expect(url).toMatchObject({ url: null, error: fakeError }); + expect(mockGetURL).toHaveBeenCalled(); + expect(mockGetURL).toHaveBeenCalledWith('getObject', { Bucket, Key, Expires: 360 }); + }); }); -}); -describe('s3Uploader.deleteFileFromJobS3', () => { - const Bucket = 'fakeBucket'; - const Key = 'fakeKey'; - const anotherFakeError = Error({ statusCode: 500 }); - it('calls deleteFileFromS3Job() with correct parameters', async () => { - const mockDeleteObject = jest.spyOn(s3, 'deleteObject').mockImplementation(() => ({ promise: () => Promise.resolve({ status: 200, data: {} }) })); - const got = deleteFileFromS3Job({ data: { fileId: 1, fileKey: Key, bucket: Bucket } }); - await expect(got).resolves.toStrictEqual({ - status: 200, data: { fileId: 1, fileKey: Key, res: { data: {}, status: 200 } }, - }); - expect(mockDeleteObject).toHaveBeenCalledWith({ Bucket, Key }); + describe('s3Uploader.deleteFileFromS3', () => { + const Bucket = 'ttadp-test'; + const Key = 'fakeKey'; + const anotherFakeError = Error('fake'); + let mockDeleteObject; + + afterEach(() => { + jest.resetAllMocks(); + }); + + beforeEach(() => { + mockS3.deleteObject = jest.fn(); + mockS3.deleteObject.mockImplementation(() => ({ promise: () => Promise.resolve('good') })); + }); + + it('throws an error if S3 is not configured', async () => { + await expect(deleteFileFromS3(Key, Bucket, null)).rejects.toThrow('S3 is not configured.'); + }); + + it('calls deleteFileFromS3() with correct parameters', async () => { + const got = deleteFileFromS3(Key, Bucket, mockS3); + await expect(got).resolves.toBe('good'); + expect(mockS3.deleteObject).toHaveBeenCalledWith({ Bucket, Key }); + }); + + it('throws an error if promise rejects', async () => { + const { bucketName } = generateS3Config(); + mockS3.deleteObject.mockImplementation( + () => ({ promise: () => Promise.reject(anotherFakeError) }), + ); + const got = deleteFileFromS3(Key, bucketName, mockS3); + await expect(got).rejects.toBe(anotherFakeError); + expect(mockS3.deleteObject).toHaveBeenCalledWith({ Bucket: bucketName, Key }); + }); }); - it('throws an error if promise rejects', async () => { - const mockDeleteObject = jest.spyOn(s3, 'deleteObject').mockImplementationOnce( - () => ({ promise: () => Promise.reject(anotherFakeError) }), - ); - const got = deleteFileFromS3Job({ data: { fileId: 1, fileKey: Key, bucket: Bucket } }); - await expect(got).resolves.toStrictEqual({ data: { bucket: 'fakeBucket', fileId: 1, fileKey: 'fakeKey' }, res: undefined, status: 500 }); - expect(mockDeleteObject).toHaveBeenCalledWith({ Bucket, Key }); + + describe('s3Uploader.deleteFileFromS3Job', () => { + const Bucket = 'ttadp-test'; + const Key = 'fakeKey'; + const anotherFakeError = Error({ statusCode: 500 }); + + beforeEach(() => { + mockS3.deleteObject = jest.fn(); + mockS3.deleteObject.mockImplementation(() => ({ + promise: () => Promise.resolve({ status: 200, data: {} }), + })); + }); + + it('returns a 500 status with error data if S3 is not configured', async () => { + const expectedOutput = { + data: { bucket: 'ttadp-test', fileId: 1, fileKey: 'fakeKey' }, + res: undefined, + status: 500, + }; + + const job = { data: { fileId: 1, fileKey: 'fakeKey', bucket: 'ttadp-test' } }; + // Pass null for s3Client to simulate S3 not being configured + const got = await deleteFileFromS3Job(job, null); + + expect(got).toStrictEqual(expectedOutput); + }); + + it('calls deleteFileFromS3Job() with correct parameters', async () => { + const { bucketName } = generateS3Config(); + const got = deleteFileFromS3Job( + { data: { fileId: 1, fileKey: Key, bucket: bucketName } }, + mockS3, + ); + await expect(got).resolves.toStrictEqual({ + status: 200, data: { fileId: 1, fileKey: Key, res: { data: {}, status: 200 } }, + }); + expect(mockS3.deleteObject).toHaveBeenCalledWith({ Bucket: bucketName, Key }); + }); + + it('throws an error if promise rejects', async () => { + const { bucketName } = generateS3Config(); + mockS3.deleteObject.mockImplementationOnce( + () => ({ + promise: () => Promise.reject(anotherFakeError), + }), + ); + + const got = deleteFileFromS3Job( + { data: { fileId: 1, fileKey: Key, bucket: bucketName } }, + mockS3, + ); + await expect(got).resolves.toStrictEqual({ + data: { bucket: bucketName, fileId: 1, fileKey: 'fakeKey' }, + res: undefined, + status: 500, + }); + expect(mockS3.deleteObject).toHaveBeenCalledWith({ Bucket: bucketName, Key }); + }); }); }); diff --git a/src/lib/updateGrantsRecipients.js b/src/lib/updateGrantsRecipients.js index 8c6c2cad7d..7d64aac1d8 100644 --- a/src/lib/updateGrantsRecipients.js +++ b/src/lib/updateGrantsRecipients.js @@ -399,7 +399,7 @@ export async function processFiles(hashSumHex) { && grantIds.includes(parseInt(g.replacement_grant_award_id, 10)), ); - const grantReplacementPromises = grantsToUpdate.map(async (g) => { + for (const g of grantsToUpdate) { let grantReplacementType = await GrantReplacementTypes.findOne({ where: { name: g.grant_replacement_type, @@ -436,9 +436,7 @@ export async function processFiles(hashSumHex) { replacementDate: new Date(g.replacement_date), }); } - }); - - await Promise.all(grantReplacementPromises); + } // --- // Update GroupGrants diff --git a/src/migrations/20241114000001-make-program-dates-datetypes.js b/src/migrations/20241114000001-make-program-dates-datetypes.js new file mode 100644 index 0000000000..c1a26d4320 --- /dev/null +++ b/src/migrations/20241114000001-make-program-dates-datetypes.js @@ -0,0 +1,39 @@ +const { prepMigration } = require('../lib/migration'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface) => queryInterface.sequelize.transaction( + async (transaction) => { + await prepMigration(queryInterface, transaction, __filename); + // Add monitor goal template. + await queryInterface.sequelize.query( + `-- change Programs startDate and endDate types to date + ALTER TABLE "Programs" ALTER COLUMN "startDate" TYPE date + USING (NULLIF("startDate", '')::date) + ; + ALTER TABLE "Programs" ALTER COLUMN "endDate" TYPE date + USING (NULLIF("endDate", '')::date) + ; + `, + { transaction }, + ); + }, + ), + + down: async (queryInterface) => queryInterface.sequelize.transaction( + async (transaction) => { + await prepMigration(queryInterface, transaction, __filename); + await queryInterface.sequelize.query( + `-- change Programs startDate and endDate types back to varchar + ALTER TABLE "Programs" ALTER COLUMN "startDate" TYPE VARCHAR(255) + USING ("startDate"::varchar(255)) + ; + ALTER TABLE "Programs" ALTER COLUMN "endDate" TYPE VARCHAR(255) + USING ("endDate"::varchar(255)) + ; + `, + { transaction }, + ); + }, + ), +}; diff --git a/src/migrations/20241114190341-remove-grant-replacement-type-dupes.js b/src/migrations/20241114190341-remove-grant-replacement-type-dupes.js new file mode 100644 index 0000000000..d820d227c5 --- /dev/null +++ b/src/migrations/20241114190341-remove-grant-replacement-type-dupes.js @@ -0,0 +1,73 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-restricted-syntax */ +const { prepMigration } = require('../lib/migration'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + + // Find duplicate GrantReplacementTypes, keeping the oldest + const [duplicates] = await queryInterface.sequelize.query( + ` + SELECT "name", array_agg(id ORDER BY "createdAt") AS ids + FROM "GrantReplacementTypes" + GROUP BY "name" + HAVING COUNT(*) > 1 + `, + { transaction }, + ); + + // Resolve duplicates in GrantReplacementTypes + for (const dup of duplicates) { + const { ids } = dup; + const [idToKeep, ...idsToRemove] = ids; + + // Update GrantReplacements to the idToKeep + await queryInterface.sequelize.query( + ` + UPDATE "GrantReplacements" + SET "grantReplacementTypeId" = ${idToKeep} + WHERE "grantReplacementTypeId" = ANY(array[${idsToRemove.join(',')}]) + `, + { transaction }, + ); + + // Delete duplicate ids from GrantReplacementTypes + await queryInterface.sequelize.query( + ` + DELETE FROM "GrantReplacementTypes" + WHERE id = ANY(array[${idsToRemove.join(',')}]) + `, + { transaction }, + ); + } + + // Remove exact duplicate GrantReplacements entries, keeping the oldest + await queryInterface.sequelize.query( + ` + DELETE FROM "GrantReplacements" gr + USING ( + SELECT + MIN(id) AS id, + "replacedGrantId", + "replacingGrantId", + "grantReplacementTypeId" + FROM "GrantReplacements" + GROUP BY "replacedGrantId", "replacingGrantId", "grantReplacementTypeId" + HAVING COUNT(*) > 1 + ) subquery + WHERE gr."id" > subquery.id + AND gr. "replacedGrantId" = subquery."replacedGrantId" + AND gr. "replacingGrantId" = subquery."replacingGrantId" + AND gr. "grantReplacementTypeId" = subquery."grantReplacementTypeId" + `, + { transaction }, + ); + }); + }, + + async down(queryInterface) {}, +}; diff --git a/src/models/program.js b/src/models/program.js index 1430048351..a2baa9aa86 100644 --- a/src/models/program.js +++ b/src/models/program.js @@ -1,6 +1,7 @@ const { Model, } = require('sequelize'); +const { formatDate } = require('../lib/modelHelpers'); export default (sequelize, DataTypes) => { class Program extends Model { @@ -23,8 +24,14 @@ export default (sequelize, DataTypes) => { }, programType: DataTypes.STRING, startYear: DataTypes.STRING, - startDate: DataTypes.STRING, - endDate: DataTypes.STRING, + startDate: { + type: DataTypes.DATEONLY, + get: formatDate, + }, + endDate: { + type: DataTypes.DATEONLY, + get: formatDate, + }, status: DataTypes.STRING, name: DataTypes.STRING, }, { diff --git a/src/models/tests/programPersonnel.test.js b/src/models/tests/programPersonnel.test.js index 3c0861786a..1b5ea4b2a1 100644 --- a/src/models/tests/programPersonnel.test.js +++ b/src/models/tests/programPersonnel.test.js @@ -56,8 +56,8 @@ describe('ProgramPersonnel', () => { programType: 'HS', startYear: '2023', status: 'active', - startDate: '2023', - endDate: '2025', + startDate: new Date('01/01/2023'), + endDate: new Date('01/01/2025'), }); ehsProgram = await Program.create({ @@ -67,8 +67,8 @@ describe('ProgramPersonnel', () => { programType: 'EHS', startYear: '2023', status: 'active', - startDate: '2023', - endDate: '2025', + startDate: new Date('01/01/2023'), + endDate: new Date('01/01/2025'), }); weirdProgram = await Program.create({ @@ -78,8 +78,8 @@ describe('ProgramPersonnel', () => { programType: 'something-weird', startYear: '2023', status: 'active', - startDate: '2023', - endDate: '2025', + startDate: new Date('01/01/2023'), + endDate: new Date('01/01/2025'), }); // Grant Personnel. diff --git a/src/routes/admin/buildInfo.test.js b/src/routes/admin/buildInfo.test.js new file mode 100644 index 0000000000..7bbc45be5e --- /dev/null +++ b/src/routes/admin/buildInfo.test.js @@ -0,0 +1,146 @@ +import httpCodes from 'http-codes'; +import simpleGit from 'simple-git'; +import buildInfo from './buildInfo'; +import { handleError } from '../../lib/apiErrorHandler'; + +jest.mock('../../lib/apiErrorHandler'); +jest.mock('simple-git'); + +// Mock simple-git instance and revparse +const mockGit = { + revparse: jest.fn(), +}; + +jest.mock('simple-git'); +simpleGit.mockReturnValue(mockGit); + +describe('buildInfo function', () => { + let req; + let res; + + beforeEach(() => { + req = {}; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + // Ensure environment variables are explicitly undefined + delete process.env.BUILD_BRANCH; + delete process.env.BUILD_COMMIT; + delete process.env.BUILD_NUMBER; + delete process.env.BUILD_TIMESTAMP; + + jest.clearAllMocks(); + simpleGit.mockReturnValue(mockGit); // Ensure the mock is applied in every test + }); + + it('returns all environment variables if they are all set', async () => { + process.env.BUILD_BRANCH = 'main'; + process.env.BUILD_COMMIT = 'abcdef1234567890'; + process.env.BUILD_NUMBER = '100'; + process.env.BUILD_TIMESTAMP = '2024-11-13T12:34:56Z'; + + await buildInfo(req, res); + + expect(res.status).toHaveBeenCalledWith(httpCodes.OK); + expect(res.json).toHaveBeenCalledWith({ + branch: 'main', + commit: 'abcdef1234567890', + buildNumber: '100', + timestamp: '2024-11-13T12:34:56Z', + }); + }); + + it('does not call Git commands in production environment', async () => { + process.env.NODE_ENV = 'production'; + delete process.env.BUILD_BRANCH; + delete process.env.BUILD_COMMIT; + + await buildInfo(req, res); + + expect(mockGit.revparse).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + branch: '', + commit: '', + buildNumber: '001', + timestamp: expect.any(String), + }); + + delete process.env.NODE_ENV; + }); + + it('falls back to Git branch if BUILD_BRANCH is not set and NODE_ENV is not production', async () => { + process.env.NODE_ENV = 'development'; // Simulating non-production + mockGit.revparse.mockResolvedValueOnce('feature-branch'); + process.env.BUILD_COMMIT = 'abcdef1234567890'; + process.env.BUILD_NUMBER = '100'; + process.env.BUILD_TIMESTAMP = '2024-11-13T12:34:56Z'; + + await buildInfo(req, res); + + expect(mockGit.revparse).toHaveBeenCalledWith(['--abbrev-ref', 'HEAD']); + expect(res.json).toHaveBeenCalledWith({ + branch: 'feature-branch', + commit: 'abcdef1234567890', + buildNumber: '100', + timestamp: '2024-11-13T12:34:56Z', + }); + }); + + it('falls back to Git commit if BUILD_COMMIT is not set and NODE_ENV is not production', async () => { + process.env.NODE_ENV = 'development'; // Simulating non-production + + // Mock sequential calls for branch and commit + mockGit.revparse + .mockResolvedValueOnce('main') // First call for branch + .mockResolvedValueOnce('1234567890abcdef'); // Second call for commit + + delete process.env.BUILD_COMMIT; // Ensure BUILD_COMMIT is not set + delete process.env.BUILD_BRANCH; + process.env.BUILD_NUMBER = '100'; + process.env.BUILD_TIMESTAMP = '2024-11-13T12:34:56Z'; + + // Call the function + await buildInfo(req, res); + + // Debugging: Check the mock calls + console.log('revparse calls:', mockGit.revparse.mock.calls); + + // Assertions + expect(mockGit.revparse).toHaveBeenCalledWith(['HEAD']); // Verify commit call + expect(res.json).toHaveBeenCalledWith({ + branch: 'main', + commit: '1234567890abcdef', // Ensure correct commit hash + buildNumber: '100', + timestamp: '2024-11-13T12:34:56Z', + }); + + delete process.env.NODE_ENV; // Clean up after test + }); + + it('handles errors if Git commands are called in non-production environment and fail', async () => { + process.env.NODE_ENV = 'development'; // Simulating non-production + const error = new Error('Git branch error'); + mockGit.revparse.mockRejectedValueOnce(error); + + await buildInfo(req, res); + + expect(handleError).toHaveBeenCalledWith(req, res, error, { namespace: 'ADMIN:BUILDINFO' }); + }); + + it('returns default values when NODE_ENV is production and environment variables are not set', async () => { + process.env.NODE_ENV = 'production'; + + await buildInfo(req, res); + + expect(res.json).toHaveBeenCalledWith({ + branch: '', + commit: '', + buildNumber: '001', + timestamp: expect.any(String), + }); + + delete process.env.NODE_ENV; + }); +}); diff --git a/src/routes/admin/buildInfo.ts b/src/routes/admin/buildInfo.ts new file mode 100644 index 0000000000..14384a5e3b --- /dev/null +++ b/src/routes/admin/buildInfo.ts @@ -0,0 +1,39 @@ +import httpCodes from 'http-codes'; +import simpleGit from 'simple-git'; +import { handleError } from '../../lib/apiErrorHandler'; + +const namespace = 'ADMIN:BUILDINFO'; +const logContext = { namespace }; +let git; + +export default async function buildInfo(req, res) { + if (!git) { + git = simpleGit(); + } + try { + // Check for existing environment variables, or fetch from Git if undefined + const branch = process.env.BUILD_BRANCH || ( + (process.env.NODE_ENV !== 'production') + ? await git.revparse(['--abbrev-ref', 'HEAD']) + : '' + ).trim(); + const commit = process.env.BUILD_COMMIT || ( + (process.env.NODE_ENV !== 'production') + ? await git.revparse(['HEAD']) + : '' + ).trim(); + const buildNumber = process.env.BUILD_NUMBER || '001'; + const timestamp = process.env.BUILD_TIMESTAMP || new Date().toISOString(); + + // Send the response with the resolved values + res.status(httpCodes.OK).json({ + branch, + commit, + buildNumber, + timestamp, + }); + } catch (err) { + // Handle any errors and log context + await handleError(req, res, err, logContext); + } +} diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index 140dc96074..e87d147add 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -13,6 +13,7 @@ import ssRouter from './ss'; import trainingReportRouter from './trainingReport'; import legacyReportRouter from './legacyReports'; import courseRouter from './course'; +import buildInfo from './buildInfo'; import userAdminAccessMiddleware from '../../middleware/userAdminAccessMiddleware'; import transactionWrapper from '../transactionWrapper'; @@ -35,5 +36,6 @@ router.use('/training-reports', trainingReportRouter); router.use('/legacy-reports', legacyReportRouter); router.use('/courses', courseRouter); router.use('/ss', ssRouter); +router.use('/buildInfo', buildInfo); export default router; diff --git a/src/scopes/activityReport/resourceAttachment.js b/src/scopes/activityReport/resourceAttachment.js index c45039c460..6b85bf2bde 100644 --- a/src/scopes/activityReport/resourceAttachment.js +++ b/src/scopes/activityReport/resourceAttachment.js @@ -9,7 +9,7 @@ const selectDistinctActivityReports = (join, having) => ` GROUP BY "ActivityReports"."id" HAVING ${having}`; -const activityReportFilesIncludeExclude = (include = true) => { +const activityReportFilesIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Files"."originalFileName" IS NULL) OR'; return selectDistinctActivityReports( @@ -18,7 +18,7 @@ const activityReportFilesIncludeExclude = (include = true) => { ); }; -const activityReportObjectiveFilesIncludeExclude = (include = true) => { +const activityReportObjectiveFilesIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Files"."originalFileName" IS NULL) OR'; return selectDistinctActivityReports( diff --git a/src/scopes/activityReport/resourceUrl.js b/src/scopes/activityReport/resourceUrl.js index d5cd7431ca..9bfe87638a 100644 --- a/src/scopes/activityReport/resourceUrl.js +++ b/src/scopes/activityReport/resourceUrl.js @@ -9,7 +9,7 @@ const selectDistinctActivityReports = (join, having) => ` GROUP BY "ActivityReports"."id" HAVING ${having}`; -const activityReportResourceIncludeExclude = (include = true) => { +const activityReportResourceIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Resources"."url" IS NULL) OR'; return selectDistinctActivityReports( @@ -18,7 +18,7 @@ const activityReportResourceIncludeExclude = (include = true) => { ); }; -const activityReportGoalResourceIncludeExclude = (include = true) => { +const activityReportGoalResourceIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Resources"."url" IS NULL) OR'; return selectDistinctActivityReports( @@ -27,7 +27,7 @@ const activityReportGoalResourceIncludeExclude = (include = true) => { ); }; -const activityReportObjectiveResourceIncludeExclude = (include = true) => { +const activityReportObjectiveResourceIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Resources"."url" IS NULL) OR'; return selectDistinctActivityReports( @@ -36,7 +36,7 @@ const activityReportObjectiveResourceIncludeExclude = (include = true) => { ); }; -const nextStepsResourceIncludeExclude = (include = true) => { +const nextStepsResourceIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Resources"."url" IS NULL) OR'; return selectDistinctActivityReports( diff --git a/src/scopes/communicationLog/index.test.js b/src/scopes/communicationLog/index.test.js index 3a3398cb32..34dbf6e1bb 100644 --- a/src/scopes/communicationLog/index.test.js +++ b/src/scopes/communicationLog/index.test.js @@ -8,6 +8,7 @@ import db from '../../models'; import { createUser, createRecipient } from '../../testUtils'; import { logsByRecipientAndScopes } from '../../services/communicationLog'; import { communicationLogFiltersToScopes } from './index'; +import { withinCommunicationDate } from './communicationDate'; describe('communicationLog filtersToScopes', () => { const userName = faker.name.findName(); @@ -208,4 +209,9 @@ describe('communicationLog filtersToScopes', () => { const { count } = await logsByRecipientAndScopes(recipient.id, 'communicationDate', 0, 'DESC', false, scopes); expect(count).toBe(1); }); + + it('returns empty when the dates split at "-" is less than 2', () => { + const out = withinCommunicationDate(['2022/10/01']); + expect(out).toMatchObject({}); + }); }); diff --git a/src/scopes/goals/index.test.js b/src/scopes/goals/index.test.js index 598206b5fe..ef2098dab9 100644 --- a/src/scopes/goals/index.test.js +++ b/src/scopes/goals/index.test.js @@ -30,6 +30,8 @@ import db, { File, } from '../../models'; import { GOAL_STATUS } from '../../constants'; +import { withoutStatus, withStatus } from './status'; +import { withoutTtaType, withTtaType } from './ttaType'; const REGION_ID = 10; @@ -528,6 +530,38 @@ describe('goal filtersToScopes', () => { expect(found.map((g) => g.name)).toContain('Goal 3'); expect(found.map((g) => g.name)).toContain('Goal 4'); }); + + it('withStatus, when statuses does not include Needs status', () => { + const out = withStatus([]); + expect(out).toMatchObject({ + [Op.or]: [], + }); + }); + + it('withoutStatus, when status includes Needs status', () => { + const out = withoutStatus(['Needs status']); + expect(out).toMatchObject({ + [Op.or]: [ + { status: { [Op.eq]: null } }, + { + [Op.and]: [{ + status: { [Op.notILike]: '%sNeeds status%s' }, + }], + }, + ], + }); + }); + }); + + describe('ttaType', () => { + it('withTtaType, empty query returns empty object', () => { + const out = withTtaType([]); + expect(out).toMatchObject({}); + }); + it('withoutTtaType, empty query returns empty object', () => { + const out = withoutTtaType([]); + expect(out).toMatchObject({}); + }); }); describe('reasons', () => { diff --git a/src/scopes/goals/reportText.js b/src/scopes/goals/reportText.js index da20332b38..5b4f42b9db 100644 --- a/src/scopes/goals/reportText.js +++ b/src/scopes/goals/reportText.js @@ -1,7 +1,7 @@ import { Op } from 'sequelize'; import { filterAssociation, selectDistinctActivityReportGoalIds } from './utils'; -const nextStepsIncludeExclude = (include = true) => { +const nextStepsIncludeExclude = (include) => { const a = include ? '' : 'bool_or("NextSteps".note IS NULL) OR'; return selectDistinctActivityReportGoalIds( @@ -11,7 +11,7 @@ const nextStepsIncludeExclude = (include = true) => { ); }; -const argsIncludeExclude = (include = true) => { +const argsIncludeExclude = (include) => { const a = include ? '' : 'bool_or("ActivityReportGoals".name IS NULL) OR'; return selectDistinctActivityReportGoalIds( @@ -20,7 +20,7 @@ const argsIncludeExclude = (include = true) => { ); }; -const objectiveTitleAndTtaProvidedIncludeExclude = (include = true) => { +const objectiveTitleAndTtaProvidedIncludeExclude = (include) => { const a = include ? '' : 'bool_or("ActivityReportObjectives".title IS NULL OR "ActivityReportObjectives"."ttaProvided" IS NULL) OR'; return selectDistinctActivityReportGoalIds( diff --git a/src/scopes/goals/resouceUrl.js b/src/scopes/goals/resouceUrl.js index 0d0c751a7e..1fed297909 100644 --- a/src/scopes/goals/resouceUrl.js +++ b/src/scopes/goals/resouceUrl.js @@ -1,7 +1,7 @@ import { Op } from 'sequelize'; import { filterAssociation, selectDistinctActivityReportGoalIds } from './utils'; -const activityReportResourceIncludeExclude = (include = true) => { +const activityReportResourceIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Resources"."url" IS NULL) OR'; return selectDistinctActivityReportGoalIds( @@ -11,7 +11,7 @@ const activityReportResourceIncludeExclude = (include = true) => { ); }; -const activityReportGoalResourceIncludeExclude = (include = true) => { +const activityReportGoalResourceIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Resources"."url" IS NULL) OR'; return selectDistinctActivityReportGoalIds( @@ -20,7 +20,7 @@ const activityReportGoalResourceIncludeExclude = (include = true) => { ); }; -const activityReportObjectiveResourceIncludeExclude = (include = true) => { +const activityReportObjectiveResourceIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Resources"."url" IS NULL) OR'; return selectDistinctActivityReportGoalIds( @@ -30,7 +30,7 @@ const activityReportObjectiveResourceIncludeExclude = (include = true) => { ); }; -const nextStepsResourceIncludeExclude = (include = true) => { +const nextStepsResourceIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Resources"."url" IS NULL) OR'; return selectDistinctActivityReportGoalIds( diff --git a/src/scopes/goals/resourceAttachment.js b/src/scopes/goals/resourceAttachment.js index d0bc682442..be2e71954a 100644 --- a/src/scopes/goals/resourceAttachment.js +++ b/src/scopes/goals/resourceAttachment.js @@ -1,7 +1,7 @@ import { Op } from 'sequelize'; import { filterAssociation, selectDistinctActivityReportGoalIds } from './utils'; -const activityReportFilesIncludeExclude = (include = true) => { +const activityReportFilesIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Files"."originalFileName" IS NULL) OR'; return selectDistinctActivityReportGoalIds( @@ -11,7 +11,7 @@ const activityReportFilesIncludeExclude = (include = true) => { ); }; -const activityReportObjectiveFilesIncludeExclude = (include = true) => { +const activityReportObjectiveFilesIncludeExclude = (include) => { const a = include ? '' : 'bool_or("Files"."originalFileName" IS NULL) OR'; return selectDistinctActivityReportGoalIds( diff --git a/src/scopes/grants/index.test.js b/src/scopes/grants/index.test.js index 2189fd94d8..263c8d6b34 100644 --- a/src/scopes/grants/index.test.js +++ b/src/scopes/grants/index.test.js @@ -276,8 +276,8 @@ describe('grant filtersToScopes', () => { id: recipients[0].id, grantId: recipients[0].id, startYear: 'no', - startDate: 'no', - endDate: 'no', + startDate: new Date('01/01/2023'), + endDate: new Date('01/01/2026'), status: 'Active', programType: 'EHS', name: 'no', @@ -288,8 +288,8 @@ describe('grant filtersToScopes', () => { id: recipients[1].id, grantId: recipients[1].id, startYear: 'no', - startDate: 'no', - endDate: 'no', + startDate: new Date('01/01/2023'), + endDate: new Date('01/01/2025'), status: 'Active', programType: 'HS', name: 'no', @@ -300,8 +300,8 @@ describe('grant filtersToScopes', () => { id: recipients[2].id, grantId: recipients[2].id, startYear: 'no', - startDate: 'no', - endDate: 'no', + startDate: new Date('01/01/2023'), + endDate: new Date('01/01/2025'), status: 'Active', programType: 'HS', name: 'no', diff --git a/src/scopes/index.test.js b/src/scopes/index.test.js new file mode 100644 index 0000000000..606894fe3a --- /dev/null +++ b/src/scopes/index.test.js @@ -0,0 +1,31 @@ +import { mergeIncludes } from '.'; + +describe('mergeIncludes', () => { + it('returns requiredIncludes when includes is not provided', () => { + const out = mergeIncludes([], [1]); + expect(out).toMatchObject([1]); + }); + + it('merges includes with requiredIncludes and removes duplicates based on "as"', () => { + const includes = [{ as: 'existing' }, { as: 'duplicate' }]; + const requiredIncludes = [{ as: 'required' }, { as: 'duplicate' }]; + const expectedOutput = [ + { as: 'existing' }, + { as: 'duplicate' }, + { as: 'required' }, + ]; + const out = mergeIncludes(includes, requiredIncludes); + expect(out).toMatchObject(expectedOutput); + }); + + it('returns only unique includes when includes has no matching "as"', () => { + const includes = [{ as: 'unique1' }]; + const requiredIncludes = [{ as: 'unique2' }]; + const expectedOutput = [ + { as: 'unique1' }, + { as: 'unique2' }, + ]; + const out = mergeIncludes(includes, requiredIncludes); + expect(out).toMatchObject(expectedOutput); + }); +}); diff --git a/src/services/activityReports.test.js b/src/services/activityReports.test.js index 9c5f6117db..ce297df625 100644 --- a/src/services/activityReports.test.js +++ b/src/services/activityReports.test.js @@ -961,8 +961,8 @@ describe('Activity report service', () => { programType: 'EHS', startYear: 'Aeons ago', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2025-01-01'), }); await Program.create({ @@ -972,8 +972,8 @@ describe('Activity report service', () => { programType: 'HS', startYear: 'The murky depths of time', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2025-01-01'), }); expect(recipientWithProgram.name).toBe('recipient with program'); @@ -1289,8 +1289,8 @@ describe('Activity report service', () => { programType: 'DWN', startYear: 'Aeons ago', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2025-01-01'), }); // create one approved legacy diff --git a/src/services/currentUser.js b/src/services/currentUser.js index 72bec5ee0f..72e8f5d44d 100644 --- a/src/services/currentUser.js +++ b/src/services/currentUser.js @@ -9,6 +9,12 @@ import findOrCreateUser from './findOrCreateUser'; import handleErrors from '../lib/apiErrorHandler'; import { validateUserAuthForAdmin } from './accessValidation'; +const namespace = 'MIDDLEWARE:CURRENT USER'; + +const logContext = { + namespace, +}; + /** * Get Current User ID * @@ -57,6 +63,12 @@ export async function currentUserId(req, res) { // Verify admin access. try { const userId = idFromSessionOrLocals(); + + if (userId === null) { + auditLogger.error('Impersonation failure. No valid user ID found in session or locals.'); + return res.sendStatus(httpCodes.UNAUTHORIZED); + } + if (!(await validateUserAuthForAdmin(Number(userId)))) { auditLogger.error(`Impersonation failure. User (${userId}) attempted to impersonate user (${impersonatedUserId}), but the session user (${userId}) is not an admin.`); return res.sendStatus(httpCodes.UNAUTHORIZED); @@ -67,7 +79,7 @@ export async function currentUserId(req, res) { return res.sendStatus(httpCodes.UNAUTHORIZED); } } catch (e) { - return handleErrors(req, res, e); + return handleErrors(req, res, e, logContext); } httpContext.set('impersonationUserId', Number(impersonatedUserId)); @@ -75,7 +87,7 @@ export async function currentUserId(req, res) { } } catch (e) { auditLogger.error(`Impersonation failure. Could not parse the Auth-Impersonation-Id header: ${e}`); - return handleErrors(req, res, e); + return handleErrors(req, res, e, logContext); } } @@ -99,7 +111,7 @@ export async function currentUserId(req, res) { /** * Retrieve User Details * - * This method retrives the current user details from HSES and finds or creates the TTA Hub user + * This method retrieves the current user details from HSES and finds or creates the TTA Hub user */ export async function retrieveUserDetails(accessToken) { const requestObj = accessToken.sign({ diff --git a/src/services/currentUser.test.js b/src/services/currentUser.test.js index d3816c8480..c46edccdf0 100644 --- a/src/services/currentUser.test.js +++ b/src/services/currentUser.test.js @@ -2,11 +2,14 @@ import {} from 'dotenv/config'; import axios from 'axios'; import httpCodes from 'http-codes'; +import httpContext from 'express-http-context'; +import isEmail from 'validator/lib/isEmail'; import { retrieveUserDetails, currentUserId } from './currentUser'; import findOrCreateUser from './findOrCreateUser'; import userInfoClassicLogin from '../mocks/classicLogin'; import userInfoPivCardLogin from '../mocks/pivCardLogin'; import { auditLogger } from '../logger'; +import { validateUserAuthForAdmin } from './accessValidation'; jest.mock('axios'); jest.mock('./findOrCreateUser'); @@ -22,6 +25,10 @@ jest.mock('../logger', () => ({ warn: jest.fn(), }, })); +jest.mock('express-http-context', () => ({ + set: jest.fn(), +})); +jest.mock('validator/lib/isEmail', () => jest.fn()); describe('currentUser', () => { beforeEach(async () => { @@ -69,9 +76,36 @@ describe('currentUser', () => { expect(mockRequest.session.uuid).toBeDefined(); }); - test('handles impersonation when Auth-Impersonation-Id header is set and user is not an admin', async () => { - const { validateUserAuthForAdmin } = await import('./accessValidation'); + test('does not bypass auth and retrieves userId from environment variables when not in production and BYPASS_AUTH is false', async () => { + process.env.NODE_ENV = 'development'; + process.env.BYPASS_AUTH = 'false'; + process.env.CURRENT_USER_ID = '999'; + + const mockRequest = { session: {}, headers: {} }; + const mockResponse = {}; + + const userId = await currentUserId(mockRequest, mockResponse); + + expect(userId).toBeNull(); + expect(mockRequest.session.userId).not.toBeDefined(); + expect(mockRequest.session.uuid).not.toBeDefined(); + }); + + test('does not set the session userId when not in production and BYPASS_AUTH is true', async () => { + process.env.NODE_ENV = 'development'; + process.env.BYPASS_AUTH = 'true'; + process.env.CURRENT_USER_ID = '999'; + + const mockRequest = { headers: {} }; + const mockResponse = {}; + + const userId = await currentUserId(mockRequest, mockResponse); + expect(userId).toEqual(999); + expect(mockRequest.session).toBeUndefined(); + }); + + test('handles impersonation when Auth-Impersonation-Id header is set and user is not an admin', async () => { const mockRequest = { headers: { 'auth-impersonation-id': JSON.stringify(200) }, session: {}, @@ -87,11 +121,10 @@ describe('currentUser', () => { await currentUserId(mockRequest, mockResponse); expect(mockResponse.sendStatus).toHaveBeenCalledWith(httpCodes.UNAUTHORIZED); - expect(auditLogger.error).toHaveBeenCalledWith(expect.stringContaining('Impersonation failure')); + expect(auditLogger.error).toHaveBeenCalledWith(expect.stringContaining('Impersonation failure. User (100) attempted to impersonate user (200), but the session user (100) is not an admin.')); }); test('handles impersonation when Auth-Impersonation-Id header is set and impersonated user is an admin', async () => { - const { validateUserAuthForAdmin } = await import('./accessValidation'); const mockRequest = { headers: { 'auth-impersonation-id': JSON.stringify(300) }, @@ -110,7 +143,142 @@ describe('currentUser', () => { await currentUserId(mockRequest, mockResponse); expect(mockResponse.sendStatus).toHaveBeenCalledWith(httpCodes.UNAUTHORIZED); - expect(auditLogger.error).toHaveBeenCalledWith(expect.stringContaining('Impersonation failure')); + expect(auditLogger.error).toHaveBeenCalledWith(expect.stringContaining('Impersonation failure. User (100) attempted to impersonate user (300), but the impersonated user is an admin.')); + }); + + test('allows impersonation when Auth-Impersonation-Id header is set and both users pass validation', async () => { + const mockRequest = { + headers: { 'auth-impersonation-id': JSON.stringify(200) }, + session: {}, + }; + const mockResponse = { + sendStatus: jest.fn(), + locals: { userId: 100 }, + }; + + validateUserAuthForAdmin + .mockResolvedValueOnce(true) // Current user is an admin + .mockResolvedValueOnce(false); // Impersonated user is not an admin + + const userId = await currentUserId(mockRequest, mockResponse); + + expect(userId).toEqual(200); + expect(mockResponse.sendStatus).not.toHaveBeenCalledWith(httpCodes.UNAUTHORIZED); + expect(httpContext.set).toHaveBeenCalledWith('impersonationUserId', 200); + }); + + test('handles invalid JSON in Auth-Impersonation-Id header gracefully', async () => { + const mockRequest = { + headers: { 'auth-impersonation-id': '{invalidJson}' }, + session: {}, + }; + const mockResponse = { + locals: {}, + status: jest.fn().mockReturnThis(), + end: jest.fn(), + }; + + await currentUserId(mockRequest, mockResponse); + + const expectedMessage = 'Could not parse the Auth-Impersonation-Id header'; + expect(auditLogger.error).toHaveBeenCalledWith(expect.stringContaining(expectedMessage)); + }); + + test('handles empty Auth-Impersonation-Id header gracefully', async () => { + process.env.NODE_ENV = 'production'; + process.env.BYPASS_AUTH = 'false'; + const mockRequest = { + headers: { 'auth-impersonation-id': '""' }, + session: {}, + }; + const mockResponse = { + sendStatus: jest.fn(), + locals: {}, + status: jest.fn().mockReturnThis(), + end: jest.fn(), + }; + + const userId = await currentUserId(mockRequest, mockResponse); + + expect(userId).toBeNull(); + expect(auditLogger.error).not.toHaveBeenCalled(); + expect(httpContext.set).not.toHaveBeenCalled(); + expect(mockResponse.sendStatus).not.toHaveBeenCalled(); + }); + + test('calls handleErrors if an error occurs during impersonation admin validation', async () => { + const mockRequest = { + headers: { 'auth-impersonation-id': JSON.stringify(155) }, + session: {}, + }; + const mockResponse = { + locals: { userId: 100 }, + status: jest.fn().mockReturnThis(), + end: jest.fn(), + }; + + validateUserAuthForAdmin.mockImplementationOnce(() => { + throw new Error('Admin validation failed'); + }); + + await currentUserId(mockRequest, mockResponse); + + const expectedMessage = 'MIDDLEWARE:CURRENT USER - UNEXPECTED ERROR - Error: Admin validation failed'; + expect(auditLogger.error).toHaveBeenCalledWith(expect.stringContaining(expectedMessage)); + }); + + test('logs error and returns UNAUTHORIZED if userId is null', async () => { + process.env.BYPASS_AUTH = 'false'; + const mockRequest = { + headers: { 'auth-impersonation-id': JSON.stringify(155) }, + session: null, + }; + const mockResponse = { + sendStatus: jest.fn(), + locals: {}, + }; + + await currentUserId(mockRequest, mockResponse); + + expect(auditLogger.error).toHaveBeenCalledWith( + 'Impersonation failure. No valid user ID found in session or locals.', + ); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(httpCodes.UNAUTHORIZED); + }); + + test('bypasses authentication with playwright-user-id header in non-production environment', async () => { + process.env.NODE_ENV = 'development'; + process.env.BYPASS_AUTH = 'true'; + const mockRequest = { + headers: { 'playwright-user-id': '123' }, + session: {}, + }; + const mockResponse = {}; + + const userId = await currentUserId(mockRequest, mockResponse); + + expect(userId).toEqual(123); + expect(mockRequest.session.userId).toEqual('123'); + expect(mockRequest.session.uuid).toBeDefined(); + expect(auditLogger.warn).toHaveBeenCalledWith( + 'Bypassing authentication in authMiddleware. Using user id 123 from playwright-user-id header.', + ); + }); + + test('does not set session if req.session is undefined', async () => { + const mockRequest = { + headers: { 'playwright-user-id': '123' }, + session: undefined, + }; + const mockResponse = {}; + + const userId = await currentUserId(mockRequest, mockResponse); + + expect(userId).toEqual(123); + expect(mockRequest.session).toBeUndefined(); + expect(auditLogger.warn).toHaveBeenCalledWith( + 'Bypassing authentication in authMiddleware. Using user id 123 from playwright-user-id header.', + ); }); }); @@ -121,6 +289,7 @@ describe('currentUser', () => { data: userInfoClassicLogin, }; axios.get.mockResolvedValueOnce(responseFromUserInfo); + isEmail.mockReturnValueOnce(true); const accessToken = { sign: jest.fn().mockReturnValue({ url: '/auth/user/me' }) }; @@ -142,6 +311,7 @@ describe('currentUser', () => { data: userInfoPivCardLogin, }; axios.get.mockResolvedValueOnce(responseFromUserInfoPiv); + isEmail.mockReturnValueOnce(true); const accessToken = { sign: jest.fn().mockReturnValue({ url: '/auth/user/me' }) }; @@ -156,5 +326,27 @@ describe('currentUser', () => { hsesUserId: '1', }); }); + + test('can handle oauth piv card login user response from HSES with null email', async () => { + const responseFromUserInfoPiv = { + status: 200, + data: userInfoPivCardLogin, + }; + axios.get.mockResolvedValueOnce(responseFromUserInfoPiv); + isEmail.mockReturnValueOnce(false); + + const accessToken = { sign: jest.fn().mockReturnValue({ url: '/auth/user/me' }) }; + + await retrieveUserDetails(accessToken); + + expect(axios.get).toHaveBeenCalled(); + expect(findOrCreateUser).toHaveBeenCalledWith({ + name: 'testUser@adhocteam.us', + email: null, + hsesUsername: 'testUser@adhocteam.us', + hsesAuthorities: ['ROLE_FEDERAL'], + hsesUserId: '1', + }); + }); }); }); diff --git a/src/services/recipient.test.js b/src/services/recipient.test.js index 7e3f2128a5..0fd77766ce 100644 --- a/src/services/recipient.test.js +++ b/src/services/recipient.test.js @@ -164,8 +164,8 @@ describe('Recipient DB service', () => { programType: 'EHS', startYear: 'Aeons ago', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2026-01-01'), }), Program.create({ id: 75, @@ -174,8 +174,8 @@ describe('Recipient DB service', () => { programType: 'HS', startYear: 'The murky depths of time', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2026-01-01'), }), Program.create({ id: 76, @@ -184,8 +184,8 @@ describe('Recipient DB service', () => { programType: 'HS', startYear: 'The murky depths of time', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2026-01-01'), }), Program.create({ id: 77, @@ -194,8 +194,8 @@ describe('Recipient DB service', () => { programType: 'HS', startYear: 'The murky depths of time', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2026-01-01'), }), Program.create({ id: 78, @@ -204,8 +204,8 @@ describe('Recipient DB service', () => { programType: 'HS', startYear: 'The murky depths of time', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2026-01-01'), }), Program.create({ id: 79, @@ -214,8 +214,8 @@ describe('Recipient DB service', () => { programType: 'HS', startYear: 'The murky depths of time', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2026-01-01'), }), Program.create({ id: 80, @@ -224,8 +224,8 @@ describe('Recipient DB service', () => { programType: 'HS', startYear: 'The murky depths of time', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2026-01-01'), }), Program.create({ id: 81, @@ -234,8 +234,8 @@ describe('Recipient DB service', () => { programType: 'HS', startYear: 'The murky depths of time', status: 'active', - startDate: 'today', - endDate: 'tomorrow', + startDate: new Date('2023-01-01'), + endDate: new Date('2026-01-01'), }), ]); }); diff --git a/src/tools/processData.js b/src/tools/processData.js index ba324b156f..ae243e398d 100644 --- a/src/tools/processData.js +++ b/src/tools/processData.js @@ -3,23 +3,16 @@ /* eslint-disable no-restricted-syntax */ /* eslint-disable no-loop-func */ /* eslint-disable no-await-in-loop */ -import { Op } from 'sequelize'; -import cheerio from 'cheerio'; + import faker from '@faker-js/faker'; import { - ActivityReport, User, - Recipient, - Grant, - File, Permission, RequestErrors, - GrantNumberLink, - MonitoringReviewGrantee, - MonitoringClassSummary, sequelize, } from '../models'; +// Define constants representing different permission scopes that can be assigned to users const SITE_ACCESS = 1; const ADMIN = 2; const READ_WRITE_REPORTS = 3; @@ -27,25 +20,24 @@ const READ_REPORTS = 4; const APPROVE_REPORTS = 5; /** - * processData script replaces user names, emails, recipient and grant information, - * file names as well as certain html fields with generated data while preserving - * existing relationships and non-PII data. - * - * Resulting anonymized database can then be restored in non-production environments. + * The processData script is responsible for anonymizing sensitive user data, including names, + * emails, recipient information, grant details, and certain HTML fields. + * This anonymization ensures that the resulting database, which can then be restored in + * non-production environments, preserves existing relationships and non-personally identifiable + * information (non-PII) data. */ +// Arrays to hold the original real user data and the transformed anonymized user data let realUsers = []; let transformedUsers = []; -let transformedRecipients = []; -let realGrants = []; -let transformedGrants = []; +let realRecipients = []; + +// Predefined list of users from HSES (Head Start Enterprise System) with their details such as +// name, username, user ID, and email const hsesUsers = [ { name: 'Adam Levin', hsesUsername: 'test.tta.adam', hsesUserId: '50783', email: 'adam.levin@adhocteam.us', }, - { - name: 'Angela Waner', hsesUsername: 'test.tta.angela', hsesUserId: '50599', email: 'angela.waner@adhocteam.us', - }, { name: 'Krys Wisnaskas', hsesUsername: 'test.tta.krys', hsesUserId: '50491', email: 'krystyna@adhocteam.us', }, @@ -61,18 +53,12 @@ const hsesUsers = [ { name: 'Maria Puhl', hsesUsername: 'test.tta.maria', hsesUserId: '51298', email: 'maria.puhl@adhocteam.us', }, - { - name: 'Patrice Pascual', hsesUsername: 'test.tta.patrice', hsesUserId: '45594', email: 'patrice.pascual@acf.hhs.gov', - }, { name: 'Nathan Powell', hsesUsername: 'test.tta.nathan', hsesUserId: '51379', email: 'nathan.powell@adhocteam.us', }, { name: 'Garrett Hill', hsesUsername: 'test.tta.garrett', hsesUserId: '51548', email: 'garrett.hill@adhocteam.us', }, - { - name: 'Adam Roux', hsesUsername: 'test.tta.adamr', hsesUserId: '52047', email: 'adam.roux@adhocteam.us', - }, { name: 'C\'era Oliveira-Norris', hsesUsername: 'test.tta.c\'era', hsesUserId: '52075', email: 'c\'era.oliveira-norris@adhocteam.us', }, @@ -82,9 +68,6 @@ const hsesUsers = [ { name: 'Jon Pyers', hsesUsername: 'test.tta.jon', hsesUserId: '52829', email: 'jon.pyers@adhocteam.us', }, - { - name: 'Abby Blue', hsesUsername: 'test.tta.abby', hsesUserId: '53043', email: 'abby.blue@adhocteam.us', - }, { name: 'Patrick Deutsch', hsesUsername: 'test.tta.patrick', hsesUserId: '53137', email: 'patrick.deutsch@adhocteam.us', }, @@ -93,240 +76,574 @@ const hsesUsers = [ }, ]; +// A helper function to generate a fake email address by prefixing 'no-send_' to a randomly +// generated email using the faker library const generateFakeEmail = () => 'no-send_'.concat(faker.internet.email()); -const processHtml = async (input) => { - if (!input) { - return input; - } +// chr(92) represents the backslash (\) character in ASCII. This prevents JavaScript from +// interfering with the escape sequences in your SQL regular expression when you pass the +// query as a string in sequelize.query. - const $ = cheerio.load(input); +// Function to create a PL/pgSQL function in the PostgreSQL database that processes HTML content by +// replacing words with randomly generated words +const processHtmlCreate = async () => sequelize.query(/* sql */` + CREATE OR REPLACE FUNCTION "processHtml"(input TEXT) RETURNS TEXT LANGUAGE plpgsql AS $$ + DECLARE + result TEXT; + new_word TEXT; + BEGIN + IF input IS NULL OR input = '' THEN + RETURN input; + END IF; - const getTextNodes = (elem) => (elem.type === 'text' ? [] : elem.contents().toArray() - .filter((el) => el !== undefined) - .reduce((acc, el) => acc.concat(...el.type === 'text' ? [el] : getTextNodes($(el))), [])); + -- Replace each word in the input with a random word from a predefined list + result := regexp_replace( + input, + chr(92) || 'w+', -- Match words using a regular expression + ( + SELECT string_agg(word, ' ') + FROM ( + SELECT (ARRAY['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur'])[floor(random() * 6 + 1)::int] AS word + FROM generate_series(1, regexp_count(input, chr(92) || 'w+')) + ) AS subquery + ), + 'g' -- Global flag to replace all occurrences + ); - getTextNodes($('html')).map((node) => $(node).replaceWith( - $.html(node).trim() === '' // empty - ? faker.random.words(0) - : faker.random.words($.html(node).split(' ').length), - )); + RETURN result; + END $$; +`); - return cheerio.load($.html(), null, false).html(); // html minus the html, head and body tags -}; +// Function to drop the "processHtml" function from the PostgreSQL database if it exists +const processHtmlDrop = async () => sequelize.query(/* sql */` + DROP FUNCTION IF EXISTS "processHtml"(TEXT); +`); -export const convertEmails = (emails) => { - if (!emails) { - return emails; - } - const emailsArray = emails.split(', '); - const convertedEmails = emailsArray.map((email) => { - const foundUser = realUsers.find((user) => user.email === email); - const userId = foundUser ? foundUser.id : null; - if (userId) { - const foundTransformedUser = transformedUsers.find((user) => user.id === userId); - return foundTransformedUser ? foundTransformedUser.email : ''; - } - return emails.includes('@') ? generateFakeEmail() : ''; - }); +// Function to create a PL/pgSQL function in the PostgreSQL database that converts email addresses +// by either finding a corresponding anonymized email or generating a fake one +const convertEmailsCreate = async () => sequelize.query(/* sql */` + CREATE OR REPLACE FUNCTION "convertEmails"(emails TEXT) RETURNS TEXT LANGUAGE plpgsql AS $$ + DECLARE + emails_array TEXT[]; + converted_emails TEXT[]; + email TEXT; + converted_email TEXT; + domain TEXT; + BEGIN + IF emails IS NULL OR emails = '' THEN + RETURN emails; + END IF; - return convertedEmails.join(', '); -}; + -- Split the input emails string into an array of individual email addresses + emails_array := string_to_array(emails, ', '); + + -- Initialize the array to store the converted (anonymized) emails + converted_emails := ARRAY[]::TEXT[]; + + -- Iterate through each email in the array + FOREACH email IN ARRAY emails_array LOOP + -- Try to find a corresponding anonymized email from the ZALUsers table within the last 30 minutes and with the current transaction ID + SELECT zu.new_row_data ->> 'email' + INTO converted_email + FROM "ZALUsers" zu + WHERE zu.old_row_data ->> 'email' = email + AND zu.dml_timestamp >= NOW() - INTERVAL '30 minutes' + AND zu.dml_txid = lpad(txid_current()::text, 32, '0')::uuid; + + -- If a converted email is found, add it to the array + IF converted_email IS NOT NULL AND converted_email <> '' THEN + converted_emails := array_append(converted_emails, converted_email); + ELSE + -- If no converted email is found, generate a fake email address + IF email LIKE '%@%' THEN + -- Extract the domain from the email + domain := SPLIT_PART(email, '@', 2); + + -- Generate the fake email using the md5 hash of the original username and a random domain from the Users table + converted_email := 'no-send_' || md5(SPLIT_PART(email, '@', 1)) || '@' || ( + SELECT email_domain FROM ( + SELECT SPLIT_PART(e.email, '@', 2) AS email_domain + FROM "Users" e + WHERE NULLIF(TRIM(SPLIT_PART(e.email, '@', 2)), '') IS NOT NULL + ORDER BY RANDOM() + LIMIT 1 + ) AS random_domain + ); + + -- Add the generated fake email to the array + converted_emails := array_append(converted_emails, converted_email); + ELSE + -- If the email is not valid, add an empty string to the array + converted_emails := array_append(converted_emails, ''); + END IF; + END IF; + END LOOP; + -- Return the array of converted emails as a comma-separated string + RETURN array_to_string(converted_emails, ', '); + END $$; +`); + +// Function to drop the "convertEmails" function from the PostgreSQL database if it exists +const convertEmailsDrop = async () => sequelize.query(/* sql */` + DROP FUNCTION IF EXISTS "convertEmails"(TEXT); +`); + +// Function to convert a user's name and email to anonymized data, ensuring consistent +// anonymization across the dataset export const convertName = (name, email) => { if (!name) { return { name, email }; } - const additionalId = 99999; + const additionalId = 99999; // Arbitrary ID to use for users not found in the realUsers array let foundUser = realUsers.find((user) => user.email === email); - // Not all program specialists or grant specialist are in the Hub yet - // Add it to the realUsers + // If the user is not found and the email contains '@', add the user to the realUsers array if (!foundUser && email.includes('@')) { foundUser = { id: additionalId + 1, name, email }; realUsers.push(foundUser); } + // Find the corresponding transformed (anonymized) user data let foundTransformedUser = transformedUsers.find((user) => user.id === foundUser.id); if (!foundTransformedUser) { + // If the transformed user is not found, create a new transformed user with a fake name + // and email foundTransformedUser = { id: foundUser.id, - name: faker.name.findName(), - email: generateFakeEmail(), + name: faker.name.findName(), // Generate a fake name + email: generateFakeEmail(), // Generate a fake email }; transformedUsers.push(foundTransformedUser); } return foundTransformedUser; }; -export const convertFileName = (fileName) => { - if (fileName === null) { - return fileName; - } - const extension = fileName.slice(fileName.indexOf('.')); - return `${faker.system.fileName()}${extension}`; -}; +const convertUserNameCreate = async () => sequelize.query(/* sql */` +CREATE OR REPLACE FUNCTION "convertUserName"(user_name TEXT, user_id INT) +RETURNS TEXT LANGUAGE plpgsql AS $$ +DECLARE + transformed_name TEXT; +BEGIN + IF user_name IS NULL THEN + RETURN 'Unknown Name'; + END IF; -export const convertRecipientName = (recipientsGrants) => { - if (recipientsGrants === null) { - return recipientsGrants; - } + -- Remove leading and trailing whitespace from the user name + user_name := trim(user_name); - const recipientGrantsArray = recipientsGrants ? recipientsGrants.split('\n') : []; + -- Perform the conversion using the provided SQL logic + SELECT zul.new_row_data ->> 'name' + INTO transformed_name + FROM "ZALUsers" zul + JOIN "Users" u ON zul.data_id = u.id + WHERE u.name = user_name + AND zul.dml_timestamp >= NOW() - INTERVAL '30 minutes' + AND zul.dml_txid = lpad(txid_current()::text, 32, chr(48))::uuid; - const convertedRecipientsGrants = recipientGrantsArray.map((recipientGrant) => { - const recipientGrantArray = recipientGrant.split('|'); - const grant = recipientGrantArray.length > 1 ? recipientGrantArray[1].trim() : 'Missing Grant'; + -- Handle cases where no match was found and assign default value + IF transformed_name IS NULL THEN + SELECT zul.new_row_data ->> 'name' + INTO transformed_name + FROM "ZALUsers" zul + JOIN "Users" u ON zul.data_id = u.id + WHERE u.id = user_id + AND zul.dml_timestamp >= NOW() - INTERVAL '30 minutes' + AND zul.dml_txid = lpad(txid_current()::text, 32, chr(48))::uuid; + END IF; - const foundGrant = realGrants.find((g) => g.number === grant); - // get ids of real grants and recipients; - const recipientId = foundGrant ? foundGrant.recipientId : null; - const grantId = foundGrant ? foundGrant.id : null; - // find corresponding transformed grants and recipients - const foundTransformedRecipient = transformedRecipients.find((g) => g.id === recipientId); - const foundTransformedGrant = transformedGrants.find((g) => g.id === grantId); + -- Handle cases where no match was found and assign default value + IF transformed_name IS NULL THEN + transformed_name := 'Unknown Name'; + END IF; - const transformedRecipientName = foundTransformedRecipient ? foundTransformedRecipient.name : 'Unknown Recipient'; - const transformedGrantNumber = foundTransformedGrant ? foundTransformedGrant.number : 'UnknownGrant'; - return `${transformedRecipientName} | ${transformedGrantNumber}`; - }); + RETURN transformed_name; +END $$; - return convertedRecipientsGrants.join('\n'); -}; +`); +const convertUserNameDrop = async () => sequelize.query(/* sql */` + DROP FUNCTION IF EXISTS "convertUserName"(TEXT, INT); +`); + +// Function to create a PL/pgSQL function in the PostgreSQL database that converts recipient names +// and grant numbers to anonymized data +const convertRecipientNameAndNumberCreate = async () => sequelize.query(/* sql */` + CREATE OR REPLACE FUNCTION "convertRecipientNameAndNumber"(recipients_grants TEXT) RETURNS TEXT LANGUAGE plpgsql AS $$ + DECLARE + recipient_grants_array TEXT[]; + converted_recipients_grants TEXT[]; + recipient_grant TEXT; + grant_number TEXT; -- Renamed from 'grant' to 'grant_number' + transformed_recipient_name TEXT; + transformed_grant_number TEXT; + BEGIN + IF recipients_grants IS NULL THEN + RETURN recipients_grants; + END IF; + + -- Split the recipients_grants string into an array of recipient-grant pairs + recipient_grants_array := string_to_array(recipients_grants, chr(92) || 'n'); + + -- Initialize the array to store the converted recipient-grant pairs + converted_recipients_grants := ARRAY[]::TEXT[]; + + -- Iterate through each recipient-grant pair + FOREACH recipient_grant IN ARRAY recipient_grants_array LOOP + -- Extract the grant number from the pair + grant_number := split_part(recipient_grant, '|', 2); + + -- Remove leading and trailing whitespace from the grant number + grant_number := trim(grant_number); + + -- Perform the conversion using the provided SQL logic + SELECT zgr.new_row_data ->> 'number', r.name + INTO transformed_grant_number, transformed_recipient_name + FROM "ZALGrants" zgr + JOIN "Grants" gr ON zgr.data_id = gr.id + JOIN "Recipients" r ON gr."recipientId" = r.id + WHERE zgr.old_row_data ->> 'number' = grant_number + AND zgr.dml_timestamp >= NOW() - INTERVAL '30 minutes' + AND zgr.dml_txid = lpad(txid_current()::text, 32, chr(48))::uuid; -- Use chr(48) for '0' + + -- Handle cases where no match was found and assign default values + IF transformed_grant_number IS NULL THEN + transformed_grant_number := 'UnknownGrant'; + END IF; + IF transformed_recipient_name IS NULL THEN + transformed_recipient_name := 'Unknown Recipient'; + END IF; + + -- Construct the converted recipient-grant pair and add it to the array + converted_recipients_grants := array_append( + converted_recipients_grants, + transformed_recipient_name || ' | ' || transformed_grant_number + ); + END LOOP; + + -- Return the converted recipient-grant pairs as a string + RETURN array_to_string(converted_recipients_grants, chr(92) || 'n'); + END $$; +`); + +// Function to drop the "convertRecipientNameAndNumber" function from the PostgreSQL +// database if it exists +const convertRecipientNameAndNumberDrop = async () => sequelize.query(/* sql */` + DROP FUNCTION IF EXISTS "convertRecipientNameAndNumber"(TEXT); +`); + +const convertGrantNumberCreate = async () => sequelize.query(/* sql */` + CREATE OR REPLACE FUNCTION "convertGrantNumber"(grant_number TEXT, grant_id INT) + RETURNS TEXT LANGUAGE plpgsql AS $$ + DECLARE + transformed_grant_number TEXT; + BEGIN + IF grant_number IS NULL THEN + RETURN 'UnknownGrant'; + END IF; + + -- Remove leading and trailing whitespace from the grant number + grant_number := trim(grant_number); + + -- Perform the conversion using the provided SQL logic + SELECT zgr.new_row_data ->> 'number' + INTO transformed_grant_number + FROM "ZALGrants" zgr + JOIN "Grants" gr ON zgr.data_id = gr.id + WHERE zgr.old_row_data ->> 'number' = grant_number + AND zgr.dml_timestamp >= NOW() - INTERVAL '30 minutes' + AND zgr.dml_txid = lpad(txid_current()::text, 32, chr(48))::uuid; + + -- Handle cases where no match was found and assign default value + IF transformed_grant_number IS NULL THEN + SELECT zgr.new_row_data ->> 'number' + INTO transformed_grant_number + FROM "ZALGrants" zgr + JOIN "Grants" gr ON zrec.data_id = gr.id + WHERE gr.id = grant_id + AND zrec.dml_timestamp >= NOW() - INTERVAL '30 minutes' + AND zrec.dml_txid = lpad(txid_current()::text, 32, chr(48))::uuid; + END IF; + + -- Handle cases where no match was found and assign default value + IF transformed_grant_number IS NULL THEN + transformed_grant_number := 'UnknownGrant'; + END IF; + + RETURN transformed_grant_number; + END $$; +`); + +const convertGrantNumberDrop = async () => sequelize.query(/* sql */` + DROP FUNCTION IF EXISTS "convertGrantNumber"(TEXT, INT); +`); + +const convertRecipientNameCreate = async () => sequelize.query(/* sql */` + CREATE OR REPLACE FUNCTION "convertRecipientName"(recipient_name TEXT, grant_id INT) + RETURNS TEXT LANGUAGE plpgsql AS $$ + DECLARE + transformed_recipient_name TEXT; + BEGIN + IF recipient_name IS NULL THEN + RETURN 'Unknown Recipient'; + END IF; + + -- Remove leading and trailing whitespace from the recipient name + recipient_name := trim(recipient_name); + + -- Perform the conversion using the provided SQL logic + SELECT zrec.new_row_data ->> 'name' + INTO transformed_recipient_name + FROM "ZALRecipients" zrec + JOIN "Recipients" r ON zrec.data_id = r.id + WHERE r.name = recipient_name + AND zrec.dml_timestamp >= NOW() - INTERVAL '30 minutes' + AND zrec.dml_txid = lpad(txid_current()::text, 32, chr(48))::uuid; + + -- Handle cases where no match was found and assign default value + IF transformed_recipient_name IS NULL THEN + SELECT zrec.new_row_data ->> 'name' + INTO transformed_recipient_name + FROM "ZALRecipients" zrec + JOIN "Grants" gr ON zrec.data_id = gr."recipientId" + WHERE gr.id = grant_id + AND zrec.dml_timestamp >= NOW() - INTERVAL '30 minutes' + AND zrec.dml_txid = lpad(txid_current()::text, 32, chr(48))::uuid; + END IF; + + -- Handle cases where no match was found and assign default value + IF transformed_recipient_name IS NULL THEN + transformed_recipient_name := 'Unknown Recipient'; + END IF; + + RETURN transformed_recipient_name; + END $$; +`); + +const convertRecipientNameDrop = async () => sequelize.query(/* sql */` + DROP FUNCTION IF EXISTS "convertRecipientName"(TEXT, INT); +`); + +// Function to anonymize user data by replacing names, emails, and other details with generated +// fake data export const hideUsers = async (userIds) => { + // Prepare the WHERE clause for the query based on the provided user IDs, if any const ids = userIds || null; - const where = ids ? { id: ids } : {}; - // save real users - realUsers = (await User.findAll({ - attributes: ['id', 'email', 'name'], - where, - })).map((u) => u.dataValues); - - const users = await User.findAll({ - where, + const whereClause = ids ? `AND "id" IN (${ids.join(', ')})` : ''; + + // Query the database to retrieve real user data based on the WHERE clause + [realUsers] = await sequelize.query(/* sql */` + SELECT "id", "email", "name" + FROM "Users" --test + WHERE 1 = 1 + ${whereClause}; + `); + + const usedHsesUsernames = new Set(); + const usedEmails = new Set(); + + // Generate anonymized data for each user + const fakeData = realUsers.map((user) => { + let hsesUsername; + let email; + + // Ensure that the generated HSES username is unique + do { + hsesUsername = faker.internet.email(); + } while (usedHsesUsernames.has(hsesUsername)); + usedHsesUsernames.add(hsesUsername); + + // Ensure that the generated email is unique + do { + email = `no-send_${faker.internet.email()}`; + } while (usedEmails.has(email)); + usedEmails.add(email); + + return { + id: user.id, + hsesUsername, + email, + // Generate a fake phone number + phoneNumber: faker.phone.phoneNumber(), + // Generate a fake name and remove any single quotes + name: faker.name.findName().replace(/'/g, ''), + }; + }); + + // Update the Users table in the database with the anonymized data using a Common Table + // Expression (CTE) + await sequelize.query(/* sql */` + WITH fake_data AS ( + SELECT + jsonb_array_elements(:fakeDataJSON::jsonb) AS data + ) + UPDATE "Users" + SET + "hsesUsername" = data->>'hsesUsername', + "email" = data->>'email', + "phoneNumber" = data->>'phoneNumber', + "name" = data->>'name' + FROM fake_data + WHERE "Users"."id" = (data->>'id')::int + ${whereClause}; + `, { + replacements: { fakeDataJSON: JSON.stringify(fakeData) }, }); - const promises = []; - // loop through the found users - for (const user of users) { - promises.push( - user.update({ - hsesUsername: faker.internet.email(), - email: generateFakeEmail(), - phoneNumber: faker.phone.phoneNumber(), - name: faker.name.findName(), - }, { individualHooks: true }), - ); - } - await Promise.all(promises); - // Retrieve transformed users - transformedUsers = (await User.findAll({ - attributes: ['id', 'email', 'name'], - })).map((u) => u.dataValues); + // Retrieve the transformed (anonymized) user data from the Users table for further processing + [transformedUsers] = await sequelize.query(/* sql */` + SELECT "id", "email", "name" + FROM "Users" -- test 2 + WHERE 1 = 1 + ${whereClause}; + `); }; +// Function to anonymize recipient and grant data by replacing names and grant numbers with +// generated fake data export const hideRecipientsGrants = async (recipientsGrants) => { - realGrants = (await Grant.findAll({ - attributes: ['id', 'recipientId', 'number'], - })).map((g) => g.dataValues); - - const recipientsArray = recipientsGrants ? recipientsGrants.split('\n').map((el) => el.split('|')[0].trim()) : null; - const grantsArray = (recipientsArray && recipientsArray.length > 1) ? recipientsGrants.split('\n').map((el) => el.split('|')[1].trim()) : null; + // Parse the recipientsGrants input string into arrays of recipients and grants + const recipientsArray = recipientsGrants + ? recipientsGrants.split('\n').map((el) => el.split('|')[0].trim()) + : null; + const grantsArray = (recipientsArray && recipientsArray.length > 1) + ? recipientsGrants.split('\n').map((el) => el.split('|')[1].trim()) + : null; const recipientWhere = recipientsArray - ? { name: { [Op.like]: { [Op.any]: recipientsArray } } } - : {}; - const grantWhere = grantsArray ? { number: { [Op.like]: { [Op.any]: grantsArray } } } : {}; - const recipients = await Recipient.findAll({ - where: recipientWhere, - }); + ? `WHERE "name" ILIKE ANY(ARRAY[${recipientsArray.map((r) => `'${r}'`).join(', ')}])` + : ''; + const grantWhere = grantsArray + ? `WHERE "number" ILIKE ANY(ARRAY[${grantsArray.map((g) => `'${g}'`).join(', ')}])` + : ''; - const promises = []; - const promisesMonitoring = []; + // Query the database to retrieve real recipient data based on the WHERE clause + [realRecipients] = await sequelize.query(/* sql */` + SELECT "id", "name" + FROM "Recipients" + ${recipientWhere}; + `); - // loop through the found reports - for (const recipient of recipients) { - promises.push( - recipient.update({ - name: faker.company.companyName(), - }, { individualHooks: true }), - ); - } - const grants = await Grant.findAll({ - where: grantWhere, - }); + // Generate anonymized data for each recipient + const fakeRecipientData = realRecipients.map((recipient) => ({ + id: recipient.id, + // Generate a fake company name and remove any single quotes + name: faker.company.companyName().replace(/'/g, ''), + })); + + // Query the database to retrieve real grant data based on the WHERE clause + const [grants] = await sequelize.query(/* sql */` + SELECT "id", "number", "programSpecialistName", "programSpecialistEmail", "grantSpecialistName", "grantSpecialistEmail" + FROM "Grants" + ${grantWhere}; + `); - for (const grant of grants) { - // run this first + // Generate anonymized data for each grant + const fakeGrantData = grants.map((grant) => { + // Anonymize the program specialist's name and email const programSpecialist = convertName( grant.programSpecialistName, grant.programSpecialistEmail, ); + // Anonymize the grant specialist's name and email const grantSpecialist = convertName( grant.grantSpecialistName, grant.grantSpecialistEmail, ); + // Generate a new grant number with a random animal type and trailing ID const trailingNumber = grant.id; const newGrantNumber = `0${faker.datatype.number({ min: 1, max: 9 })}${faker.animal.type()}0${trailingNumber}`; + return { + id: grant.id, + number: newGrantNumber, + programSpecialistName: programSpecialist.name, + programSpecialistEmail: programSpecialist.email, + grantSpecialistName: grantSpecialist.name, + grantSpecialistEmail: grantSpecialist.email, + }; + }); - promises.push( - grant.update({ - number: newGrantNumber, - programSpecialistName: programSpecialist.name, - programSpecialistEmail: programSpecialist.email, - grantSpecialistName: grantSpecialist.name, - grantSpecialistEmail: grantSpecialist.email, - }, { individualHooks: true }), - ); - } - await Promise.all(promises); - const oldGrantNumbers = []; - - for (const grant of grants) { - const newGrantNumber = grant.number; - const oldGrantNumber = await GrantNumberLink.findOne({ - attributes: ['grantNumber'], - where: { grantId: grant.id, grantNumber: { [Op.ne]: grant.number } }, - }); - if (oldGrantNumber) { - oldGrantNumbers.push(oldGrantNumber.grantNumber); - // Update corresponding MonitoringReviewGrantee records - promisesMonitoring.push( - MonitoringReviewGrantee.update( - { grantNumber: newGrantNumber }, - { where: { grantNumber: oldGrantNumber.grantNumber } }, - ), - ); - // Update corresponding MonitoringClassSummary records - promisesMonitoring.push( - MonitoringClassSummary.update( - { grantNumber: newGrantNumber }, - { where: { grantNumber: oldGrantNumber.grantNumber } }, - ), - ); - } - } + // Convert the anonymized recipient and grant data into JSON strings for SQL processing + const fakeRecipientDataJSON = JSON.stringify(fakeRecipientData); + const fakeGrantDataJSON = JSON.stringify(fakeGrantData); - await Promise.all(promisesMonitoring); + // Update the Recipients table in the database with the anonymized recipient data using a Common + // Table Expression (CTE) + await sequelize.query(/* sql */` + WITH fake_recipients AS ( + SELECT + jsonb_array_elements('${fakeRecipientDataJSON}'::jsonb) AS data + ) + UPDATE "Recipients" + SET + "name" = data->>'name' + FROM fake_recipients + WHERE "Recipients"."id" = (data->>'id')::int; + `); - await GrantNumberLink.unscoped().destroy({ - where: { grantNumber: { [Op.in]: oldGrantNumbers } }, - force: true, - }); + // Update the Grants table in the database with the anonymized grant data using a Common Table + // Expression (CTE) + await sequelize.query(/* sql */` + WITH fake_grants AS ( + SELECT + jsonb_array_elements('${fakeGrantDataJSON}'::jsonb) AS data + ) + UPDATE "Grants" + SET + "number" = data->>'number', + "programSpecialistName" = data->>'programSpecialistName', + "programSpecialistEmail" = data->>'programSpecialistEmail', + "grantSpecialistName" = data->>'grantSpecialistName', + "grantSpecialistEmail" = data->>'grantSpecialistEmail' + FROM fake_grants + WHERE "Grants"."id" = (data->>'id')::int; + `); + + // Bulk update related tables MonitoringReviewGrantee, MonitoringClassSummary, and + // GrantNumberLink with the new anonymized grant numbers + await sequelize.query(/* sql */` + -- 1. Disable the foreign key constraints temporarily to allow data modification + ALTER TABLE "MonitoringReviewGrantees" DROP CONSTRAINT "MonitoringReviewGrantees_grantNumber_fkey"; + ALTER TABLE "MonitoringClassSummaries" DROP CONSTRAINT "MonitoringClassSummaries_grantNumber_fkey"; + + -- 2. Perform the data modifications + -- Update MonitoringReviewGrantee table with new grant numbers + UPDATE "MonitoringReviewGrantees" mrg + SET "grantNumber" = gr.number + FROM "GrantNumberLinks" gnl + JOIN "Grants" gr ON gnl."grantId" = gr.id + AND gnl."grantNumber" != gr.number + WHERE mrg."grantNumber" = gnl."grantNumber"; + + -- Update MonitoringClassSummary table with new grant numbers + UPDATE "MonitoringClassSummaries" mcs + SET "grantNumber" = gr.number + FROM "GrantNumberLinks" gnl + JOIN "Grants" gr ON gnl."grantId" = gr.id + AND gnl."grantNumber" != gr.number + WHERE mcs."grantNumber" = gnl."grantNumber"; - // Retrieve transformed recipients - transformedRecipients = (await Recipient.findAll({ - attributes: ['id', 'name'], - where: { id: recipients.map((g) => g.id) }, - })).map((g) => g.dataValues); - - // Retrieve transformed grants - transformedGrants = (await Grant.findAll({ - attributes: ['id', 'number'], - where: { id: grants.map((g) => g.id) }, - })).map((g) => g.dataValues); + -- Update GrantNumberLink table to reflect the new grant numbers + UPDATE "GrantNumberLinks" gnl + SET "grantNumber" = gr.number + FROM "Grants" gr + WHERE gnl."grantId" = gr.id + AND gnl."grantNumber" != gr.number; + + -- 3. Re-add the foreign key constraints with NOT VALID to allow revalidation later + ALTER TABLE "MonitoringReviewGrantees" ADD CONSTRAINT "MonitoringReviewGrantees_grantNumber_fkey" + FOREIGN KEY ("grantNumber") REFERENCES "GrantNumberLinks"("grantNumber") NOT VALID; + + ALTER TABLE "MonitoringClassSummaries" ADD CONSTRAINT "MonitoringClassSummaries_grantNumber_fkey" + FOREIGN KEY ("grantNumber") REFERENCES "GrantNumberLinks"("grantNumber") NOT VALID; + + -- 4. Revalidate the foreign key constraints to ensure data integrity + ALTER TABLE "MonitoringReviewGrantees" VALIDATE CONSTRAINT "MonitoringReviewGrantees_grantNumber_fkey"; + ALTER TABLE "MonitoringClassSummaries" VALIDATE CONSTRAINT "MonitoringClassSummaries_grantNumber_fkey"; + `); }; +// Function to generate a set of permissions for a user based on their user ID and predefined +// permission scopes const givePermissions = (id) => { const permissionsArray = [ { @@ -351,6 +668,7 @@ const givePermissions = (id) => { }, ]; + // Loop to generate READ_REPORTS permissions for regions 1 through 12 for (let region = 1; region < 13; region++) { permissionsArray.push({ userId: id, @@ -361,6 +679,9 @@ const givePermissions = (id) => { return permissionsArray; }; + +// Function to bootstrap HSES users into the system by either creating or updating them, and +// assigning appropriate permissions export const bootstrapUsers = async () => { const userPromises = []; for await (const hsesUser of hsesUsers) { @@ -375,13 +696,17 @@ export const bootstrapUsers = async () => { }; if (user) { id = user.id; + // If the user already exists, update their details userPromises.push(user.update(newUser, { individualHooks: true })); + // Assign permissions to the user for (const permission of givePermissions(id)) { userPromises.push(Permission.findOrCreate({ where: permission })); } } else { + // If the user does not exist, create a new user const createdUser = await User.create(newUser); if (createdUser) { + // Assign permissions to the newly created user for (const permission of givePermissions(createdUser.id)) { userPromises.push(Permission.findOrCreate({ where: permission })); } @@ -392,7 +717,10 @@ export const bootstrapUsers = async () => { } }; +// Function to truncate audit tables in the database while disabling and re-enabling triggers export const truncateAuditTables = async () => { + // Query the database to find all audit tables (tables starting with 'ZAL') except for specific + // ones that should not be truncated const tablesToTruncate = await sequelize.query(` SELECT table_name FROM information_schema.tables WHERE @@ -400,120 +728,357 @@ export const truncateAuditTables = async () => { table_name not in ('ZALDDL', 'ZALZADescriptor', 'ZALZAFilter') `, { raw: true }); + // Iterate through each table and perform truncation for await (const table of tablesToTruncate) { + // Disable triggers before truncating the table await sequelize.query(`ALTER TABLE "${table}" DISABLE TRIGGER all`); + // Truncate the table and restart its identity sequence await sequelize.query(`TRUNCATE TABLE "${table}" RESTART IDENTITY`); + // Re-enable triggers after truncating the table await sequelize.query(`ALTER TABLE "${table}" ENABLE TRIGGER all`); } }; -const processData = async (mockReport) => sequelize.transaction(async () => { - const activityReportId = mockReport ? mockReport.id : null; - const where = activityReportId ? { id: activityReportId } : {}; - const userIds = mockReport ? [3000, 3001, 3002, 3003] : null; +// Function to anonymize file names by replacing them with randomly generated file names while +// preserving their original extensions +export const processFiles = async () => sequelize.query(/* sql */` + UPDATE "Files" + SET "originalFileName" = + CONCAT( + SUBSTRING(md5(random()::text), 1, 8), -- Generate a random file name using MD5 hash + SUBSTRING("originalFileName" FROM '\\..*$') -- Preserve the original file extension + ) + WHERE "originalFileName" IS NOT NULL; +`); - const recipientsGrants = mockReport ? mockReport.imported.granteeName : null; - const reports = await ActivityReport.unscoped().findAll({ - where, - }); +// Function to process and anonymize sensitive data in Activity Reports by replacing specific +// fields with generated fake data +export const processActivityReports = async ( + where = '', +) => sequelize.query(/* sql */`-- Update additionalNotes field + UPDATE "ActivityReports" + SET "additionalNotes" = "processHtml"("additionalNotes") + WHERE "additionalNotes" IS NOT NULL + ${where}; + + -- Update context field + UPDATE "ActivityReports" + SET "context" = "processHtml"("context") + WHERE "context" IS NOT NULL + ${where}; + + -- Update imported -> 'additionalNotesForThisActivity' + UPDATE "ActivityReports" + SET "imported" = jsonb_set( + "imported", + '{additionalNotesForThisActivity}', + to_jsonb("processHtml"("imported"->>'additionalNotesForThisActivity')), + true + ) + WHERE "imported" IS NOT NULL AND "imported"->>'additionalNotesForThisActivity' IS NOT NULL + ${where}; + + -- Update imported -> 'cdiGranteeName' + UPDATE "ActivityReports" + SET "imported" = jsonb_set( + "imported", + '{cdiGranteeName}', + to_jsonb("processHtml"("imported"->>'cdiGranteeName')), + true + ) + WHERE "imported" IS NOT NULL AND "imported"->>'cdiGranteeName' IS NOT NULL + ${where}; + + -- Update imported -> 'contextForThisActivity' + UPDATE "ActivityReports" + SET "imported" = jsonb_set( + "imported", + '{contextForThisActivity}', + to_jsonb("processHtml"("imported"->>'contextForThisActivity')), + true + ) + WHERE "imported" IS NOT NULL AND "imported"->>'contextForThisActivity' IS NOT NULL + ${where}; + + -- Update imported -> 'createdBy' using convertEmails() + UPDATE "ActivityReports" + SET "imported" = jsonb_set( + "imported", + '{createdBy}', + to_jsonb("convertEmails"("imported"->>'createdBy')), + true + ) + WHERE "imported" IS NOT NULL AND "imported"->>'createdBy' IS NOT NULL + ${where}; + + -- Update imported -> 'granteeFollowUpTasksObjectives' + UPDATE "ActivityReports" + SET "imported" = jsonb_set( + "imported", + '{granteeFollowUpTasksObjectives}', + to_jsonb("processHtml"("imported"->>'granteeFollowUpTasksObjectives')), + true + ) + WHERE "imported" IS NOT NULL AND "imported"->>'granteeFollowUpTasksObjectives' IS NOT NULL + ${where}; + + -- Update imported -> 'granteeName' using convertRecipientNameAndNumber() + UPDATE "ActivityReports" + SET "imported" = jsonb_set( + "imported", + '{granteeName}', + to_jsonb( + array_to_string( + array( + SELECT "convertRecipientNameAndNumber"(unnest(string_to_array("imported"->>'granteeName', E'\n'))) + ), + E'\n' + ) + ), + true + ) + WHERE "imported" IS NOT NULL + AND "imported"->>'granteeName' IS NOT NULL + ${where}; + + -- Update imported -> 'manager' using convertEmails() + UPDATE "ActivityReports" + SET "imported" = jsonb_set( + "imported", + '{manager}', + to_jsonb("convertEmails"("imported"->>'manager')), + true + ) + WHERE "imported" IS NOT NULL AND "imported"->>'manager' IS NOT NULL + ${where}; + + -- Update imported -> 'modifiedBy' using convertEmails() + UPDATE "ActivityReports" + SET "imported" = jsonb_set( + "imported", + '{modifiedBy}', + to_jsonb("convertEmails"("imported"->>'modifiedBy')), + true + ) + WHERE "imported" IS NOT NULL AND "imported"->>'modifiedBy' IS NOT NULL + ${where}; + + -- Update imported -> 'otherSpecialists' using convertEmails() + UPDATE "ActivityReports" + SET "imported" = jsonb_set( + "imported", + '{otherSpecialists}', + to_jsonb("convertEmails"("imported"->>'otherSpecialists')), + true + ) + WHERE "imported" IS NOT NULL AND "imported"->>'otherSpecialists' IS NOT NULL + ${where}; + + -- Update imported -> 'specialistFollowUpTasksObjectives' + UPDATE "ActivityReports" + SET "imported" = jsonb_set( + "imported", + '{specialistFollowUpTasksObjectives}', + to_jsonb("processHtml"("imported"->>'specialistFollowUpTasksObjectives')), + true + ) + WHERE "imported" IS NOT NULL AND "imported"->>'specialistFollowUpTasksObjectives' IS NOT NULL + ${where}; +`); - const files = await File.findAll(); +export const processTraningReports = async (where = '') => { + // Event + await sequelize.query(/* sql */` + -- 1. Update data -> 'creator' field using convertEmails() + UPDATE "EventReportPilots" + SET data = jsonb_set( + data, + '{creator}', + CASE + WHEN "convertEmails"(data ->> 'creator') IS NOT NULL THEN to_jsonb("convertEmails"(data ->> 'creator')) + ELSE data -> 'creator' + END, + false + ) + WHERE data ? 'creator' + ${where}; - const promises = []; + -- 2. Update data -> 'owner' -> 'name' field using convertUserName() + UPDATE "EventReportPilots" + SET data = jsonb_set( + data, + '{owner, name}', + CASE + WHEN "convertUserName"(data #>> '{owner, name}', (data #>> '{owner, id}')::int) IS NOT NULL THEN to_jsonb("convertUserName"(data #>> '{owner, name}', (data #>> '{owner, id}')::int)) + ELSE data #> '{owner, name}' + END, + false + ) + WHERE data ? 'owner' AND data->'owner' ? 'name' + ${where}; - // Hide users - await hideUsers(userIds); - // Hide recipients and grants - await hideRecipientsGrants(recipientsGrants); + -- 3. Update data -> 'owner' -> 'email' field using convertEmails() + UPDATE "EventReportPilots" + SET data = jsonb_set( + data, + '{owner, email}', + CASE + WHEN "convertEmails"(data #>> '{owner, email}') IS NOT NULL THEN to_jsonb("convertEmails"(data #>> '{owner, email}')) + ELSE data #> '{owner, email}' + END, + false + ) + WHERE data ? 'owner' AND data->'owner' ? 'email' + ${where}; - // loop through the found reports - for await (const report of reports) { - const { imported } = report; + -- 4. Update data -> 'owner' -> 'nameWithNationalCenters' field with a suffix, using convertUserName() + UPDATE "EventReportPilots" + SET data = jsonb_set( + data, + '{owner, nameWithNationalCenters}', + CASE + WHEN "convertUserName"(split_part(data #>> '{owner, nameWithNationalCenters}', ',', 1), (data #>> '{owner, id}')::int) IS NOT NULL THEN + to_jsonb("convertUserName"(split_part(data #>> '{owner, nameWithNationalCenters}', ',', 1), (data #>> '{owner, id}')::int) || ', ' || split_part(data #>> '{owner, nameWithNationalCenters}', ',', 2)) + ELSE data #> '{owner, nameWithNationalCenters}' + END, + false + ) + WHERE data ? 'owner' AND data->'owner' ? 'nameWithNationalCenters' + ${where}; - promises.push( - report.update({ - managerNotes: await processHtml(report.managerNotes), - additionalNotes: await processHtml(report.additionalNotes), - context: await processHtml(report.context), - }, { individualHooks: true }), - ); - if (imported) { - // TODO: ttaProvided needs to move from ActivityReportObjective to ActivityReportObjective - const newImported = { - additionalNotesForThisActivity: await processHtml( - imported.additionalNotesForThisActivity, - ), - cdiGranteeName: await processHtml(imported.cdiGranteeName), - contextForThisActivity: await processHtml( - imported.contextForThisActivity, + -- 5. Update each element's userName in eventReportPilotNationalCenterUsers array using convertUserName() + UPDATE "EventReportPilots" + SET data = jsonb_set( + data, + '{eventReportPilotNationalCenterUsers}', + ( + SELECT jsonb_agg( + CASE + WHEN "convertUserName"(user_elem ->> 'userName', (user_elem ->> 'userId')::int) IS NOT NULL THEN + jsonb_set(user_elem, '{userName}', to_jsonb("convertUserName"(user_elem ->> 'userName', (user_elem ->> 'userId')::int))) + ELSE user_elem + END + ) + FROM jsonb_array_elements(data->'eventReportPilotNationalCenterUsers') AS user_elem ), - created: imported.created, - createdBy: convertEmails(imported.createdBy), - duration: imported.duration, - endDate: imported.endDate, - format: imported.format, - goal1: imported.goal1, - goal2: imported.goal2, - granteeFollowUpTasksObjectives: await processHtml( - imported.granteeFollowUpTasksObjectives, + false + ) + WHERE data ? 'eventReportPilotNationalCenterUsers' + ${where}; + `); + // Session + await sequelize.query(/* sql */` + UPDATE "SessionReportPilots" + SET data = jsonb_set( + data, + '{recipients}', + COALESCE( + ( + SELECT jsonb_agg(new_recipient) + FROM ( + SELECT + jsonb_set( + recipient, + '{label}', + to_jsonb(new_label) + ) AS new_recipient + FROM ( + SELECT + recipient, + -- Reconstruct the new label + CASE + WHEN array_length(reversed_parts, 1) >= 3 THEN + "convertRecipientName"(REVERSE(array_to_string(reversed_parts[3:array_upper(reversed_parts, 1)], ' - ')), "value") || ' - ' || + "convertGrantNumber"(REVERSE(reversed_parts[2]), "value") || ' - ' || + REVERSE(reversed_parts[1]) + WHEN array_length(reversed_parts, 1) = 2 THEN + "convertRecipientName"(REVERSE(reversed_parts[2]), "value") || ' - ' || + "convertGrantNumber"(REVERSE(reversed_parts[1]), "value") + WHEN array_length(reversed_parts, 1) = 1 THEN + "convertRecipientName"(REVERSE(reversed_parts[1]), "value") + ELSE + recipient ->> 'label' + END AS new_label + FROM ( + SELECT + recipient, + REVERSE(recipient ->> 'label') AS reversed_label, + string_to_array(REVERSE(recipient ->> 'label'), ' - ') AS reversed_parts, + (recipient ->> 'value')::int "value" + FROM jsonb_array_elements(data->'recipients') AS recipient + ) sub1 + ) sub2 + ) sub3 + ), + data->'recipients' -- Fallback to original value if transformation fails ), - granteeName: convertRecipientName(imported.granteeName), - granteeParticipants: imported.granteeParticipants, - granteesLearningLevelGoal1: imported.granteesLearningLevelGoal1, - granteesLearningLevelGoal2: imported.granteesLearningLevelGoal2, - manager: convertEmails(imported.manager), - modified: imported.modified, - modifiedBy: convertEmails(imported.modifiedBy), - multiGranteeActivities: imported.multiGranteeActivities, - nonGranteeActivity: imported.nonGranteeActivity, - nonGranteeParticipants: imported.nonGranteeParticipants, - nonOhsResources: imported.nonOhsResources, - numberOfParticipants: imported.numberOfParticipants, - objective11: imported.objective11, - objective11Status: imported.objective11Status, - objective12: imported.objective12, - objective12Status: imported.objective12Status, - objective21: imported.objective21, - objective21Status: imported.objective21Status, - objective22: imported.objective22, - objective22Status: imported.objective22Status, - otherSpecialists: convertEmails(imported.otherSpecialists), - otherTopics: imported.otherTopics, - programType: imported.programType, - reasons: imported.reasons, - reportId: imported.reportId, - resourcesUsed: imported.resourcesUsed, - sourceOfRequest: imported.sourceOfRequest, - specialistFollowUpTasksObjectives: await processHtml( - imported.specialistFollowUpTasksObjectives, - ), - startDate: imported.startDate, - tTa: imported.tTa, - targetPopulations: imported.targetPopulations, - topics: imported.topics, - ttaProvidedAndGranteeProgressMade: imported.ttaProvidedAndGranteeProgressMade, - }; - promises.push(report.update({ imported: newImported }, { individualHooks: true })); - } - } + false + ) + WHERE data ? 'recipients' + ${where}; + `); +}; - for (const file of files) { - promises.push( - file.update({ - originalFileName: convertFileName(file.originalFileName), - }, { individualHooks: true }), - ); +/* Main function to orchestrate the entire anonymization process, including creating and dropping +* database functions, hiding users, recipients, and grants, processing activity reports and files, +* and truncating audit tables +*/ +const processData = async (mockReport) => sequelize.transaction(async () => { + // If a mockReport is provided, extract the activity report ID and relevant data + let activityReportId = null; + let where = ''; + let userIds = null; + let recipientsGrants = null; + + if (mockReport) { + activityReportId = mockReport.id; + where = `AND id = ${activityReportId}`; + userIds = [3000, 3001, 3002, 3003]; + recipientsGrants = mockReport.imported.granteeName; } + // Create the necessary database functions for data processing + await processHtmlCreate(); + await convertEmailsCreate(); + await convertRecipientNameAndNumberCreate(); + await convertGrantNumberCreate(); + await convertRecipientNameCreate(); + await convertUserNameCreate(); + + // Anonymize user data + await hideUsers(userIds); + + // Anonymize recipient and grant data + await hideRecipientsGrants(recipientsGrants); + + // Anonymize activity reports + await processActivityReports(where); + + await processTraningReports(); + + // Anonymize file names + await processFiles(); + + // Bootstrap HSES users and assign permissions await bootstrapUsers(); - // Delete from RequestErrors + // Delete all records from the RequestErrors table await RequestErrors.destroy({ where: {}, truncate: true, }); - await Promise.all(promises); + + // Drop the database functions used for data processing + await processHtmlDrop(); + await convertEmailsDrop(); + await convertRecipientNameAndNumberDrop(); + await convertGrantNumberDrop(); + await convertRecipientNameDrop(); + await convertUserNameDrop(); + + // Truncate audit tables return truncateAuditTables(); }); +// Export the main processData function as the default export of the module export default processData; diff --git a/src/tools/processData.test.js b/src/tools/processData.test.js index 2a55775e20..dd44f1a051 100644 --- a/src/tools/processData.test.js +++ b/src/tools/processData.test.js @@ -26,10 +26,7 @@ import processData, { hideUsers, hideRecipientsGrants, bootstrapUsers, - convertEmails, - convertName, - convertFileName, - convertRecipientName, + convertName, // Kept as it's still used in the main code } from './processData'; jest.mock('../logger'); @@ -95,7 +92,6 @@ const mockFile = { fileSize: 54417, }; -// TODO: ttaProvided needs to move from ActivityReportObjective to ActivityReportObjective const reportObject = { activityRecipientType: 'recipient', userId: mockUser.id, @@ -326,10 +322,11 @@ describe('processData', () => { await Grant.unscoped().destroy({ where: { id: GRANT_ID_TWO }, individualHooks: true }); await Recipient.unscoped().destroy({ where: { id: RECIPIENT_ID_ONE } }); await Recipient.unscoped().destroy({ where: { id: RECIPIENT_ID_TWO } }); + await destroyMonitoringData(); await sequelize.close(); }); - it('transforms user emails, recipientName in the ActivityReports table (imported)', async () => { + it('transforms user emails and recipient names in the ActivityReports table (imported)', async () => { const report = await ActivityReport.create(reportObject); mockActivityReportFile.activityReportId = report.id; await ActivityReportFile.destroy({ where: { id: mockActivityReportFile.id } }); @@ -369,7 +366,7 @@ describe('processData', () => { describe('hideUsers', () => { it('transforms user names and emails in the Users table', async () => { - await hideUsers(mockUser.id.toString()); + await hideUsers([mockUser.id]); const transformedMockUser = await User.findOne({ where: { id: mockUser.id } }); expect(transformedMockUser.email).not.toBe(mockUser.email); expect(transformedMockUser.hsesUsername).not.toBe(mockUser.hsesUsername); @@ -389,7 +386,8 @@ describe('processData', () => { const transformedRecipient = await Recipient.findOne({ where: { id: RECIPIENT_ID_ONE } }); expect(transformedRecipient.name).not.toBe('Agency One, Inc.'); }); - it('transforms grant names in the Grants table', async () => { + + it('transforms grant numbers in the Grants table', async () => { await hideRecipientsGrants(reportObject.imported.granteeName); const transformedGrant = await Grant.findOne({ where: { recipientId: RECIPIENT_ID_ONE } }); @@ -427,19 +425,18 @@ describe('processData', () => { it('updates grant numbers in the MonitoringReviewGrantee table', async () => { await hideRecipientsGrants(reportObject.imported.granteeName); - // Find the updated record + // Verify that no record with the old grant number exists anymore const monitoringReviewGranteeRecord = await MonitoringReviewGrantee.findOne({ where: { grantNumber: GRANT_NUMBER_ONE }, }); - // Verify that no record with the old grant number exists anymore expect(monitoringReviewGranteeRecord).toBeNull(); + // Verify that no record with the old grant number exists anymore const monitoringClassSummaryRecord = await MonitoringClassSummary.findOne({ where: { grantNumber: GRANT_NUMBER_ONE }, }); - // Verify that no record with the old grant number exists anymore expect(monitoringClassSummaryRecord).toBeNull(); }); }); @@ -452,6 +449,7 @@ describe('processData', () => { expect(user.homeRegionId).toBe(14); }); + it('gives permissions to users', async () => { await bootstrapUsers(); @@ -461,24 +459,6 @@ describe('processData', () => { }); }); - describe('convertEmails', () => { - it('handles null emails', async () => { - const emails = convertEmails(null); - expect(emails).toBe(null); - }); - - it('handles emails lacking a @', async () => { - const emails = convertEmails('test,test2@test.com,test3'); - expect(emails.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)).toBeTruthy(); - }); - - it('should convert a single email address to a transformed email address', () => { - const input = 'real@example.com'; - const output = convertEmails(input); - expect(output).toMatch(/^no-send_/); - }); - }); - describe('convertName', () => { it('handles a program specialist not in the hub', async () => { const name = await convertName('test', 'test@test.com'); @@ -489,37 +469,4 @@ describe('processData', () => { }); }); }); - - describe('convertFileName', () => { - it('handles null file names', async () => { - const fileName = await convertFileName(null); - expect(fileName).toBe(null); - }); - }); - - describe('convertRecipientName', () => { - it('handles null recipient names', async () => { - const recipientName = await convertRecipientName(null); - expect(recipientName).toBe(null); - }); - - it('converts recipient grants correctly', () => { - const recipientsGrants = 'John Doe|01HP044445\nJane Doe|09CH011111'; - const expected = 'Unknown Recipient | UnknownGrant\nUnknown Recipient | UnknownGrant'; - const result = convertRecipientName(recipientsGrants); - expect(result).toBe(expected); - }); - - it('handles missing grants', () => { - const recipientsGrants = 'John Doe|Missing\nJane Doe|'; - const expected = 'Unknown Recipient | UnknownGrant\nUnknown Recipient | UnknownGrant'; - const result = convertRecipientName(recipientsGrants); - expect(result).toBe(expected); - }); - - it('returns an empty string for empty input', () => { - const result = convertRecipientName(''); - expect(result).toBe(''); - }); - }); }); diff --git a/src/widgets/totalHrsAndRecipientGraph.js b/src/widgets/totalHrsAndRecipientGraph.js index 3aff5918fd..37a1a40acf 100644 --- a/src/widgets/totalHrsAndRecipientGraph.js +++ b/src/widgets/totalHrsAndRecipientGraph.js @@ -1,6 +1,6 @@ import { Op } from 'sequelize'; import moment from 'moment'; -import { REPORT_STATUSES } from '@ttahub/common'; +import { REPORT_STATUSES, TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS } from '@ttahub/common'; import { ActivityReport } from '../models'; function addOrUpdateResponse(traceIndex, res, xValue, valueToAdd, month) { @@ -41,13 +41,28 @@ export default async function totalHrsAndRecipientGraph(scopes, query) { // Build out return Graph data. const res = [ { - name: 'Hours of Training', x: [], y: [], month: [], + name: 'Hours of Technical Assistance', + x: [], + y: [], + month: [], + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.TECHNICAL_ASSISTANCE, + trace: 'circle', }, { - name: 'Hours of Technical Assistance', x: [], y: [], month: [], + name: 'Hours of Both', + x: [], + y: [], + month: [], + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.BOTH, + trace: 'triangle', }, { - name: 'Hours of Both', x: [], y: [], month: [], + name: 'Hours of Training', + x: [], + y: [], + month: [], + id: TOTAL_HOURS_AND_RECIPIENT_GRAPH_TRACE_IDS.TRAINING, + trace: 'square', }, ]; diff --git a/tests/api/widgets.spec.ts b/tests/api/widgets.spec.ts index 493488166b..8c025da444 100644 --- a/tests/api/widgets.spec.ts +++ b/tests/api/widgets.spec.ts @@ -57,6 +57,8 @@ test.describe('widgets', () => { const schema = Joi.array().items( Joi.object({ name: Joi.string().required(), + id: Joi.string().required(), + trace: Joi.string().required(), x: Joi.array().items(Joi.string()).required(), y: Joi.array().items(Joi.number()).required(), month: Joi.array().items(Joi.boolean()).required() diff --git a/yarn.lock b/yarn.lock index ce6c7ac8a2..dff535672a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3261,10 +3261,10 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== -"@ttahub/common@^2.1.6": - version "2.1.6" - resolved "https://registry.yarnpkg.com/@ttahub/common/-/common-2.1.6.tgz#259d98201d394eafce7686f8334768091af4655d" - integrity sha512-/X/suR8B5aKYuVXXRHa1gjBTMzzz7vyXDCwATkZ4McQhoil8dtzndYgACDFY5bC+ZsEIfqiTcDQ+Ssle1N9mbA== +"@ttahub/common@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@ttahub/common/-/common-2.1.7.tgz#739668720f08874b04ec21a428e7453737a5cfb3" + integrity sha512-LNV8DmklA2jwztAF8KOcK3/SFdJNzWCn+o6QquMxGztN8YIzsDoxik9zoygCVtVQwUQo7Y5XXPA9h3fwkUHjag== "@types/argparse@1.0.38": version "1.0.38" @@ -11822,7 +11822,7 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -simple-git@^3.19.1: +simple-git@3.19.1: version "3.19.1" resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.19.1.tgz#ff9c021961a3d876a1b115b1893bed9a28855d30" integrity sha512-Ck+rcjVaE1HotraRAS8u/+xgTvToTuoMkT9/l9lvuP5jftwnYUp6DwuJzsKErHgfyRk8IB8pqGHWEbM3tLgV1w==