diff --git a/VERSION b/VERSION index f90b1af..0bee604 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.2 +2.3.3 diff --git a/api_endpoints.localhost.json b/api_endpoints.localhost.json new file mode 100644 index 0000000..b9a33cf --- /dev/null +++ b/api_endpoints.localhost.json @@ -0,0 +1,870 @@ +{ + "_comment_services": "Service names below are Docker hostnames on the gateway_hubmap network. List with: docker network inspect gateway_hubmap | grep Name", + "_comment_url_ingest-api": "http://localhost:12123", + "_comment_url_uuid-api": "http://localhost:2222", + "_comment_url_entity-api": "http://localhost:3333", + "_comment_url_search-api": "http://localhost:4444", + "entity-api": [ + { + "method": "GET", + "endpoint": "/" + }, + { + "method": "GET", + "endpoint": "/status" + }, + { + "method": "GET", + "endpoint": "/usergroups", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/entity-types" + }, + { + "method": "GET", + "endpoint": "/entities/<*>" + }, + { + "method": "PUT", + "endpoint": "/entities/<*>", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/entities/<*>", + "authorizer": "create" + }, + { + "method": "GET", + "endpoint": "/entities/<*>/provenance" + }, + { + "method": "GET", + "endpoint": "/entities/<*>/revisions" + }, + { + "method": "GET", + "endpoint": "/entities/<*>/tuplets" + }, + { + "method": "GET", + "endpoint": "/entities/<*>/collections" + }, + { + "method": "GET", + "endpoint": "/entities/<*>/uploads" + }, + { + "method": "GET", + "endpoint": "/entities/<*>/globus-url" + }, + { + "method": "GET", + "endpoint": "/entities/<*>/ancestor-organs" + }, + { + "method": "GET", + "endpoint": "/entities/<*>/siblings" + }, + { + "method": "GET", + "endpoint": "/entities/<*>/instanceof/<*>" + }, + { + "method": "GET", + "endpoint": "/entities/type/<*>/instanceof/<*>" + }, + { + "method": "POST", + "endpoint": "/entities/batch-ids" + }, + { + "method": "POST", + "endpoint": "/entities/multiple-samples/<*>", + "authorizer": "create" + }, + { + "method": "GET", + "endpoint": "/ancestors/<*>" + }, + { + "method": "GET", + "endpoint": "/descendants/<*>", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/parents/<*>" + }, + { + "method": "GET", + "endpoint": "/children/<*>", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/datasets" + }, + { + "method": "GET", + "endpoint": "/datasets/unpublished", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/datasets/sankey_data" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/prov-info" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/prov-metadata" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/revisions" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/revision" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/latest-revision" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/donors" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/samples" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/organs" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/paired-dataset" + }, + { + "method": "PUT", + "endpoint": "/datasets/<*>/retract", + "authorizer": "data-admin" + }, + { + "method": "POST", + "endpoint": "/datasets/components", + "authorizer": "create" + }, + { + "method": "GET", + "endpoint": "/dataset/globus-url/<*>" + }, + { + "method": "GET", + "endpoint": "/entities/dataset/globus-url/<*>" + }, + { + "method": "PUT", + "endpoint": "/uploads" + }, + { + "method": "GET", + "endpoint": "/previous_revisions/<*>", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/next_revisions/<*>", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/redirect/<*>" + }, + { + "method": "GET", + "endpoint": "/doi/redirect/<*>" + }, + { + "method": "GET", + "endpoint": "/collection/redirect/<*>" + }, + { + "method": "POST", + "endpoint": "/constraints" + }, + { + "method": "GET", + "endpoint": "/documents/<*>" + }, + { + "method": "DELETE", + "endpoint": "/flush-cache/<*>", + "authorizer": "read" + }, + { + "method": "DELETE", + "endpoint": "/flush-all-cache", + "authorizer": "data-admin" + }, + { + "method": "GET", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + }, + { + "method": "POST", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + }, + { + "method": "PUT", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + }, + { + "method": "DELETE", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + } + ], + "uuid-api": [ + { + "method": "GET", + "endpoint": "/" + }, + { + "method": "GET", + "endpoint": "/status" + }, + { + "method": "POST", + "endpoint": "/uuid" + }, + { + "method": "GET", + "endpoint": "/uuid/<*>" + }, + { + "method": "GET", + "endpoint": "/uuid/<*>/exists", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/hmuuid/<*>" + }, + { + "method": "POST", + "endpoint": "/hmuuid", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/hmuuid/<*>/exists", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/file-id/<*>", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/<*>/files", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/<*>/ancestors" + }, + { + "method": "GET", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + }, + { + "method": "POST", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + }, + { + "method": "PUT", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + }, + { + "method": "DELETE", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + } + ], + "search-api": [ + { + "method": "GET", + "endpoint": "/" + }, + { + "method": "GET", + "endpoint": "/status" + }, + { + "method": "GET", + "endpoint": "/search" + }, + { + "method": "POST", + "endpoint": "/search" + }, + { + "method": "GET", + "endpoint": "/indices" + }, + { + "method": "GET", + "endpoint": "/mapping" + }, + { + "method": "POST", + "endpoint": "/mget" + }, + { + "method": "GET", + "endpoint": "/attribute-values" + }, + { + "method": "GET", + "endpoint": "/reindex-status" + }, + { + "method": "GET", + "endpoint": "/reindex-status/<*>" + }, + { + "method": "GET", + "endpoint": "/param-search/<*>" + }, + { + "method": "GET", + "endpoint": "/<*>/search" + }, + { + "method": "POST", + "endpoint": "/<*>/search" + }, + { + "method": "GET", + "endpoint": "/<*>/mapping" + }, + { + "method": "GET", + "endpoint": "/<*>/attribute-values" + }, + { + "method": "POST", + "endpoint": "/<*>/mget" + }, + { + "method": "POST", + "endpoint": "/<*>/scroll-search" + }, + { + "method": "PUT", + "endpoint": "/reindex-all", + "authorizer": "data-admin" + }, + { + "method": "PUT", + "endpoint": "/reindex/<*>", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/update/<*>", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/update/<*>/<*>", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/update/<*>/<*>/<*>", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/add/<*>", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/add/<*>/<*>", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/add/<*>/<*>/<*>", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/clear-docs/<*>", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/clear-docs/<*>/<*>", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/clear-docs/<*>/<*>/<*>", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + }, + { + "method": "POST", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + }, + { + "method": "PUT", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + }, + { + "method": "DELETE", + "endpoint": "/<*>", + "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes" + } + ], + "ingest-api": [ + { + "method": "GET", + "endpoint": "/" + }, + { + "method": "GET", + "endpoint": "/favicon.ico" + }, + { + "method": "GET", + "endpoint": "/status" + }, + { + "method": "POST", + "endpoint": "/notify", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/datasets", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/datasets", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/publications", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/verifytitleinfo", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/entities/<*>" + }, + { + "method": "POST", + "endpoint": "/datasets/derived" + }, + { + "method": "PUT", + "endpoint": "/datasets/<*>/status/<*>" + }, + { + "method": "GET", + "endpoint": "/datasets/<*>/file-system-abs-path" + }, + { + "method": "POST", + "endpoint": "/datasets/file-system-abs-path" + }, + { + "method": "POST", + "endpoint": "/uploads/file-system-abs-path" + }, + { + "method": "POST", + "endpoint": "/entities/file-system-rel-path" + }, + { + "method": "POST", + "endpoint": "/entities/accessible-data-directories" + }, + { + "method": "GET", + "endpoint": "/uploads/<*>/file-system-abs-path" + }, + { + "method": "POST", + "endpoint": "/dataset/begin-extract-cell-count-from-secondary-analysis-files-async" + }, + { + "method": "POST", + "endpoint": "/datasets/ingest", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/datasets/submissions/request_ingest" + }, + { + "method": "PUT", + "endpoint": "/datasets/<*>/validate", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/datasets/<*>/publish", + "authorizer": "data-admin" + }, + { + "method": "PUT", + "endpoint": "/collections/<*>/register-doi", + "authorizer": "data-admin" + }, + { + "method": "PUT", + "endpoint": "/datasets/bulk/submit", + "authorizer": "data-admin" + }, + { + "method": "PUT", + "endpoint": "/datasets/<*>/metadata-json", + "authorizer": "data-admin" + }, + { + "method": "PUT", + "endpoint": "/datasets/<*>/submit", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/datasets/<*>/unpublish", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/datasets/<*>", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/new-collection", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/login" + }, + { + "method": "GET", + "endpoint": "/logout" + }, + { + "method": "POST", + "endpoint": "/file-upload", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/file-commit", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/file-remove", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/uploads", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/uploads/<*>/validate", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/uploads/<*>/reorganize", + "authorizer": "data-admin" + }, + { + "method": "PUT", + "endpoint": "/uploads/<*>/submit", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/uploads/<*>/arrange-into-datasets", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/metadata/data-provider-groups", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/metadata/usergroups" + }, + { + "method": "GET", + "endpoint": "/metadata/userroles", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/metadata/usercanedit/type", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/metadata/usercanedit/type/<*>", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/metadata/usercanedit/<*>", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/metadata/groups/<*>", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/metadata/source/type/<*>", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/metadata/source/<*>", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/metadata/<*>", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/metadata/validate", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/specimens/<*>/ingest-group-ids", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/entities/<*>/allowable-edit-states", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/donors/bulk-upload", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/donors/bulk", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/samples/bulk-upload", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/samples/bulk", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/sample-bulk-metadata", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/dataset/begin-extract-cell-count-from-secondary-analysis-files-async", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/dataset/extract-cell-count-from-secondary-analysis-files", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/datasets/data-status" + }, + { + "method": "GET", + "endpoint": "/uploads/data-status" + }, + { + "method": "GET", + "endpoint": "/data-ingest-board-login" + }, + { + "method": "GET", + "endpoint": "/data-ingest-board-logout" + }, + { + "method": "GET", + "endpoint": "/umls-auth" + }, + { + "method": "GET", + "endpoint": "/ubkg-download-file-list" + }, + { + "method": "POST", + "endpoint": "/datasets/components", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/assaytype" + }, + { + "method": "GET", + "endpoint": "/assaytype/<*>" + }, + { + "method": "GET", + "endpoint": "/assaytype/metadata/<*>" + }, + { + "method": "PUT", + "endpoint": "/reload-assaytypes", + "authorizer": "data-admin" + }, + { + "method": "GET", + "endpoint": "/privs/has-data-admin" + }, + { + "method": "GET", + "endpoint": "/has-pipeline-test-privs" + }, + { + "method": "POST", + "endpoint": "/datasets/<*>/submit-for-pipeline-testing", + "authorizer": "pipeline-test" + }, + { + "method": "POST", + "endpoint": "/datasets/submit-for-pipeline-testing", + "authorizer": "pipeline-test" + }, + { + "method": "POST", + "endpoint": "/datasets/validate", + "authorizer": "data-admin" + }, + { + "method": "POST", + "endpoint": "/uploads/validate", + "authorizer": "data-admin" + } + ], + "antibody-api": [ + { + "method": "GET", + "endpoint": "/" + }, + { + "method": "GET", + "endpoint": "/static/<*>" + }, + { + "method": "GET", + "endpoint": "/static/dist/<*>" + }, + { + "method": "GET", + "endpoint": "/css/<*>" + }, + { + "method": "GET", + "endpoint": "/login" + }, + { + "method": "GET", + "endpoint": "/logout" + }, + { + "method": "GET", + "endpoint": "/antibodies" + }, + { + "method": "POST", + "endpoint": "/antibodies", + "authorizer": "read" + }, + { + "method": "POST", + "endpoint": "/antibodies/import", + "authorizer": "read" + }, + { + "method": "PUT", + "endpoint": "/restore_elasticsearch", + "authorizer": "read" + }, + { + "method": "GET", + "endpoint": "/upload" + }, + { + "method": "POST", + "endpoint": "/_search" + }, + { + "method": "GET", + "endpoint": "/status" + } + ] +} diff --git a/docker-compose.development.yml b/docker-compose.development.yml index f91261a..b6b0652 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -23,7 +23,7 @@ services: - "./BUILD:/usr/src/app/BUILD" # Mount the source code to container - "./hubmap-auth/src/:/usr/src/app/src" - # Mount conf.d-test to the nginx conf.d on container + # Mount conf.d-dev to the nginx conf.d on container - "./nginx/conf.d-dev:/etc/nginx/conf.d" # Mount ssl certificates from host to container - "/etc/letsencrypt:/etc/letsencrypt" diff --git a/docker-compose.localhost.yml b/docker-compose.localhost.yml new file mode 100644 index 0000000..56f4573 --- /dev/null +++ b/docker-compose.localhost.yml @@ -0,0 +1,46 @@ +services: + + hubmap-auth: + build: + context: ./hubmap-auth + # Uncomment if tesitng against a specific branch of commons other than the PyPI package + # Will also need to use the 'git+https://github.com/hubmapconsortium/commons.git@${COMMONS_BRANCH}#egg=hubmap-commons' + # in src/requirements.txt accordingly + args: + # The commons github branch to be used during image build (default to main if not set or null) + - COMMONS_BRANCH=${COMMONS_BRANCH:-main} + # Build the image with name and tag + # Exit with an error message containing err if unset or empty in the environment + image: hubmap/hubmap-auth:${HUBMAP_AUTH_VERSION:?err} + environment: + - DEPLOY_MODE=localhost + ports: + # Proxy pass to a REST API on localhost, which will be + # port used by an API running on a PSC VM for higher tiers e.g. + # 7777 for Workspaces API on vm001.hive.psc.edu. + # Entity API on localhost Docker Container listening on port 3333 + # mapped to + - "7777:7777" + volumes: + # Mount the VERSION file and BUILD file + - "./VERSION:/usr/src/app/VERSION" + - "./BUILD:/usr/src/app/BUILD" + # Mount the source code to container + - "./hubmap-auth/src/:/usr/src/app/src" + # Mount conf.d-localhost to the nginx conf.d on container + - "./nginx/conf.d-localhost:/etc/nginx/conf.d" + # No SSL certificates mounted in container during + # localhost development, unlike containers on DEV or + # other servers + # - "/etc/letsencrypt:/etc/letsencrypt" + # Mount the API endpoints json file for API endpoints lookup + - "./api_endpoints.localhost.json:/usr/src/app/api_endpoints.json" + healthcheck: + # Replaces base healthcheck - checks localhost:7777 for entity-api instead of localhost:8080 for / + test: ["CMD", "curl", "--fail", "http://localhost:7777/status.json"] + interval: 12h # Set long on localhost to avoid logging that interferes with development + timeout: 5s + retries: 5 + start_period: 30s + networks: + - gateway_hubmap # Docker network defined in docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml index 763e7db..40a228b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,6 @@ services: - "./nginx/html:/usr/share/nginx/html" networks: - # This is the network created by gateway to enable communicaton between multiple docker-compose projects + # This is the network created by gateway to enable communication between multiple docker-compose projects gateway_hubmap: external: true diff --git a/docker-development.sh b/docker-development.sh index b52b08b..8273099 100755 --- a/docker-development.sh +++ b/docker-development.sh @@ -22,7 +22,7 @@ function generate_build_version() { truncate -s 0 BUILD # Note: echo to file appends newline echo $GIT_BRANCH_NAME:$GIT_SHORT_COMMIT_HASH >> BUILD - # Remmove the trailing newline character + # Remove the trailing newline character truncate -s -1 BUILD echo "BUILD(git branch name:short commit hash): $GIT_BRANCH_NAME:$GIT_SHORT_COMMIT_HASH" diff --git a/docker-localhost.sh b/docker-localhost.sh new file mode 100755 index 0000000..79e6707 --- /dev/null +++ b/docker-localhost.sh @@ -0,0 +1,115 @@ +#!/bin/bash + +# Print a new line and the banner +echo +echo "==================== HUBMAP-AUTH ====================" + +function tier_check() { + # Get the script name and extract DEPLOY_TIER + SCRIPT_NAME=$(basename "$0") + + # Extract deploy tier from script name (docker-*.sh pattern) + if [[ $SCRIPT_NAME =~ docker-(.*)\.sh ]]; then + DEPLOY_TIER="${BASH_REMATCH[1]}" + else + echo "Error: Script name doesn't match pattern 'docker-*.sh'" + exit 1 + fi + echo "Executing ${SCRIPT_NAME} to deploy in Docker on ${DEPLOY_TIER}" +} + +# Set the version environment variable for the docker build +# Version number is from the VERSION file +# Also remove newlines and leading/trailing slashes if present in that VERSION file +# Note: the BUILD and VERSION files are in the same dir as this script, this is different from other APIs +function export_version() { + export HUBMAP_AUTH_VERSION=$(tr -d "\n\r" < VERSION | xargs) + echo "HUBMAP_AUTH_VERSION: $HUBMAP_AUTH_VERSION" +} + +# Generate the build version based on git branch name and short commit hash and write into BUILD file +# Note: the BUILD and VERSION files are in the same dir as this script, this is different from other APIs +function generate_build_version() { + GIT_BRANCH_NAME=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p') + GIT_SHORT_COMMIT_HASH=$(git rev-parse --short HEAD) + # Clear the old BUILD version and write the new one + truncate -s 0 BUILD + # Note: echo to file appends newline + echo $GIT_BRANCH_NAME:$GIT_SHORT_COMMIT_HASH >> BUILD + # Remove the trailing newline character + truncate -s -1 BUILD + + echo "BUILD(git branch name:short commit hash): $GIT_BRANCH_NAME:$GIT_SHORT_COMMIT_HASH" +} + +# This function sets DIR to the directory in which this script itself is found. +# Thank you https://stackoverflow.com/questions/59895/how-to-get-the-source-directory-of-a-bash-script-from-within-the-script-itself +function get_dir_of_this_script () { + SCRIPT_SOURCE="${BASH_SOURCE[0]}" + while [ -h "$SCRIPT_SOURCE" ]; do # resolve $SCRIPT_SOURCE until the file is no longer a symlink + DIR="$( cd -P "$( dirname "$SCRIPT_SOURCE" )" >/dev/null 2>&1 && pwd )" + SCRIPT_SOURCE="$(readlink "$SCRIPT_SOURCE")" + [[ $SCRIPT_SOURCE != /* ]] && SCRIPT_SOURCE="$DIR/$SCRIPT_SOURCE" # if $SCRIPT_SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located + done + DIR="$( cd -P "$( dirname "$SCRIPT_SOURCE" )" >/dev/null 2>&1 && pwd )" + echo 'DIR of script:' $DIR +} + + +if [[ "$1" != "check" && "$1" != "config" && "$1" != "build" && "$1" != "start" && "$1" != "stop" && "$1" != "down" ]]; then + echo "Unknown command '$1', specify one of the following: check|config|build|start|stop|down" +else + # echo this script name and the tier expected for Docker deployment + tier_check + + # Always show the script dir + get_dir_of_this_script + + # Always export and show the version + export_version + + # Always show the build in case branch changed or new commits + generate_build_version + + # Print empty line + echo + + if [ "$1" = "check" ]; then + # Bash array + config_paths=( + 'hubmap-auth/src/instance/app.cfg' + ) + + for pth in "${config_paths[@]}"; do + if [ ! -e $pth ]; then + echo "Missing file (relative path to DIR of script) :$pth" + exit 1 + fi + done + + echo 'Checks complete, all good :)' + elif [ "$1" = "config" ]; then + docker compose -f docker-compose.yml -f docker-compose.${DEPLOY_TIER}.yml -p gateway config + elif [ "$1" = "build" ]; then + # Delete old VERSION and BUILD files if found + if [ -f "hubmap-auth/VERSION" ]; then + rm -rf hubmap-auth/VERSION + fi + + if [ -f "hubmap-auth/BUILD" ]; then + rm -rf hubmap-auth/BUILD + fi + + # Copy over the VERSION and BUILD files + cp ./VERSION hubmap-auth + cp ./BUILD hubmap-auth + + docker compose -f docker-compose.yml -f docker-compose.${DEPLOY_TIER}.yml -p gateway build --no-cache + elif [ "$1" = "start" ]; then + docker compose -f docker-compose.yml -f docker-compose.${DEPLOY_TIER}.yml -p gateway up -d + elif [ "$1" = "stop" ]; then + docker compose -f docker-compose.yml -f docker-compose.${DEPLOY_TIER}.yml -p gateway stop + elif [ "$1" = "down" ]; then + docker compose -f docker-compose.yml -f docker-compose.${DEPLOY_TIER}.yml -p gateway down + fi +fi diff --git a/hubmap-auth/hubmap-authLocalhostArchitecture.pdf b/hubmap-auth/hubmap-authLocalhostArchitecture.pdf new file mode 100644 index 0000000..35ff891 Binary files /dev/null and b/hubmap-auth/hubmap-authLocalhostArchitecture.pdf differ diff --git a/hubmap-auth/hubmap-authLocalhostArchitecture.svg b/hubmap-auth/hubmap-authLocalhostArchitecture.svg new file mode 100644 index 0000000..a32d27a --- /dev/null +++ b/hubmap-auth/hubmap-authLocalhostArchitecture.svg @@ -0,0 +1,597 @@ + + + + + + Docker Network (gateway) + + + Host Machine (of Containers generated using 'localhost' setting) + + + + hubmap-auth Container + + Host:7777 → Container:7777 + + + nginx + + + Port 7777 + (External Gateway) + CORS enabled + Public access + → uwsgi://127.0.0.1:5000 + + + Port 8000 + (Internal Auth) + No CORS + For other APIs + → uwsgi://127.0.0.1:5000 + + + uWSGI Server + Port 5000 (internal) + Handles both nginx routes + Communicates with Flask app + + + Flask Application + hubmap-auth logic + Authorization & authentication + /api_auth, /status.json, etc. + + + + + entity-api Container + + Host:3333 → Container:3333 + + + nginx + + + Port 3333 + (External API Gateway) + 1. auth_request → /api_auth + 2. If authorized → uwsgi://localhost:5000 + /api_auth → http://hubmap-auth:7777 + + + uWSGI Server + Port 5000 (internal) + Processes authorized requests + Communicates with Flask app + + + Flask Application + entity-api logic + Entity operations + /entities, /donors, /samples, etc. + + + + + + + + + + + + + + + + ① http://localhost:7777/status.json + + + ② http://localhost:3333/entities + + + ③ auth_request + (Docker network) + + + + + + + + diff --git a/hubmap-auth/src/app.py b/hubmap-auth/src/app.py index be6f55f..ed20249 100644 --- a/hubmap-auth/src/app.py +++ b/hubmap-auth/src/app.py @@ -14,6 +14,9 @@ from pathlib import Path from urllib.parse import urlparse, parse_qs +# local imports +from endpoint_authorizer import EndpointAuthorizer + # HuBMAP commons from hubmap_commons.hm_auth import AuthHelper from hubmap_commons.exceptions import HTTPException @@ -23,7 +26,9 @@ # All the API logging is forwarded to the uWSGI server and gets written into the log file `uwsgi-hubmap-auth.log` # Log rotation is handled via logrotate on the host system with a configuration file # Do NOT handle log file and rotation via the Python logging to avoid issues with multi-worker processes -logging.basicConfig(format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s', level=logging.DEBUG, datefmt='%Y-%m-%d %H:%M:%S') +logging.basicConfig(format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s' + , level=logging.DEBUG + , datefmt='%Y-%m-%d %H:%M:%S') logger = logging.getLogger(__name__) # Specify the absolute path of the instance folder and use the config file relative to the instance path @@ -76,6 +81,12 @@ # Log the full stack trace, prepend a line with our message logger.exception(msg) +#################################################################################################### +## EndpointAuthorizer instance instantiation +#################################################################################################### + +# Module-level singleton - import and use directly in app.py +endpoint_auth_instance = EndpointAuthorizer(auth_helper_instance) #################################################################################################### ## Default route @@ -108,7 +119,6 @@ def cache_clear(): logger.info("All gateway API Auth function cache cleared.") return "All function cache cleared." - # Auth for private API services # All endpoints access need to be authenticated # Direct access will see the JSON message @@ -142,10 +152,14 @@ def api_auth(): authority = request.headers.get("Host") method = request.headers.get("X-Original-Request-Method") endpoint = request.headers.get("X-Original-URI") + # Grab the IP address the request came from for logging. If there is a comma-separated + # list in X-Forwarded-For, strip off everything but the original client IP + forwarded_for = request.headers.get('X-Forwarded-For') + req_ip_addr = forwarded_for.split(',')[0].strip() if forwarded_for else request.remote_addr # method and endpoint are always not None as long as authority is not None if authority is not None: - # Load endpoints from json + # Load endpoints from JSON file, using method decorated with @cached() data = load_file(app.config['API_ENDPOINTS_FILE']) if authority in data.keys(): @@ -156,9 +170,25 @@ def api_auth(): target_endpoint = endpoint.split("?")[0] # Remove trailing slash for comparison if item['endpoint'].strip('/') == target_endpoint.strip('/'): - if api_access_allowed(item, request): + + if endpoint_auth_instance.api_access_allowed( item=item + , request=request): + endpoint_auth_instance.log_auth_decision(response_code=200 + , response_length=len(response_200.data) + , auth_req_api=authority + , method=method + , endpoint=endpoint + , client_ip=req_ip_addr + , matched_pattern=item['endpoint']) return response_200 else: + endpoint_auth_instance.log_auth_decision(response_code=401 + , response_length=len(response_401.data) + , auth_req_api=authority + , method=method + , endpoint=endpoint + , client_ip=req_ip_addr + , matched_pattern=item['endpoint']) return response_401 # Second pass, loop through the list to do the wildcard match @@ -173,19 +203,61 @@ def api_auth(): target_endpoint = endpoint.split("?")[0] # Remove trailing slash for comparison if re.fullmatch(endpoint_pattern.strip('/'), target_endpoint.strip('/')) is not None: - if api_access_allowed(item, request): + if endpoint_auth_instance.api_access_allowed( item=item + , request=request): + endpoint_auth_instance.log_auth_decision(response_code=200 + , response_length=len(response_200.data) + , auth_req_api=authority + , method=method + , endpoint=endpoint + , client_ip=req_ip_addr + , matched_pattern=item['endpoint']) + return response_200 else: + endpoint_auth_instance.log_auth_decision(response_code=401 + , response_length=len(response_401.data) + , auth_req_api=authority + , method=method + , endpoint=endpoint + , client_ip=req_ip_addr + , matched_pattern=item['endpoint']) return response_401 - # After two passes and still no match found - # It could be either unknown request method or unknown path - return response_401 - - # Handle the cases when authority not in data.keys() + # With no match to the combination of endpoint path and + # endpoint HTTP method, the proper response is an HTTP 404. + # Due to limitations in nginx's response codes, delegate generating + # a 404 Response back to the authority API by authorizing access with + # an HTTP 200 response to this subrequest. + endpoint_auth_instance.log_auth_decision(response_code=200 + , response_length=len(response_200.data) + , auth_req_api=authority + , method=method + , endpoint=endpoint + , client_ip=req_ip_addr + , matched_pattern="NO_MATCHED_ENDPOINT_DELEGATED_TO_API") + delegate_404_response = make_response(jsonify({"message": "Endpoint not configured in gateway"}), 200) + delegate_404_response.headers['X-Delegate-404-Response'] = f"{authority} should send HTTP 404 Response" + return delegate_404_response + + # Handle the cases when authority not in data.keys() + endpoint_auth_instance.log_auth_decision(response_code=401 + , response_length=len(response_401.data) + , auth_req_api=authority + , method=method + , endpoint=endpoint + , client_ip=req_ip_addr + , matched_pattern="NO_MATCHED_AUTHORITY") return response_401 else: # Missing lookup_key + endpoint_auth_instance.log_auth_decision(response_code=401 + , response_length=len(response_401.data) + , auth_req_api=authority + , method=method + , endpoint=endpoint + , client_ip=req_ip_addr + , matched_pattern="UNKNOWN_SERVICE") return response_401 @@ -478,7 +550,6 @@ def get_status_data(): # Final result return status_data - # Get user information dict based on the http request(headers) # `group_required` is a boolean, when True, 'hmgroupids' is in the output def get_user_info_for_access_check(request, group_required): @@ -726,64 +797,6 @@ def validate_umls_key(umls_key): else: return False - -# Always pass through the requests with using modified version of the globus app secret as internal token -def is_secrect_token(request): - internal_token = auth_helper_instance.getProcessSecret() - parsed_token = None - - if 'Authorization' in request.headers: - auth_header = request.headers['Authorization'] - parsed_token = auth_header[6:].strip() - - if internal_token == parsed_token: - return True - - return False - - -# Check if access to the given endpoint item is allowed -# Also check if the globus token associated user is a member of the specified group associated with the endpoint item -def api_access_allowed(item, request): - logger.info("======Matched endpoint======") - logger.info(item) - - # Check if auth is required for this endpoint - if item['auth'] == False: - return True - - # Check if using modified version of the globus app secret as internal token - if is_secrect_token(request): - return True - - # When auth is required, we need to check if group access is also required - group_required = True if 'groups' in item else False - - # Get user info and do further parsing - user_info = get_user_info_for_access_check(request, group_required) - - logger.info("======user_info======") - logger.info(user_info) - - # If returns error response, invalid header or token - if isinstance(user_info, Response): - return False - - # Otherwise, user_info is a dict and we check if the group ID of target endpoint can be found - # in user_info['hmgroupids'] list - # Key 'hmgroupids' presents only when group_required is True - if group_required: - for group in user_info['hmgroupids']: - if group in item['groups']: - return True - - # None of the assigned groups match the group ID specified in item['groups'] - return False - - # When no group access required and user_info dict gets returned - return True - - # If the given uuid is a file uuid, get the parent entity uuid # If the given uuid itself is an entity uuid, just return it # The bool entity_is_avr is returned as a flag diff --git a/hubmap-auth/src/endpoint_authorizer.py b/hubmap-auth/src/endpoint_authorizer.py new file mode 100644 index 0000000..bda09ce --- /dev/null +++ b/hubmap-auth/src/endpoint_authorizer.py @@ -0,0 +1,234 @@ +""" +EndpointAuthorizer - evaluates whether a request is authorized to access +a matched API endpoint item. + +Usage in app.py: + from endpoint_authorizer import endpoint_authorizer + + if endpoint_authorizer.api_access_allowed(item, request): + ... +""" + +import logging +import warnings +import time +from datetime import datetime, timezone + +# HuBMAP commons +from hubmap_commons.hm_auth import AuthHelper + +# local imports +import gateway_exceptions as gwEx + +# For the hooks used to log endpoint usage, set the level to use while +# logging these events by using an unnamed level as close to the configured +# level, but high enough to log. +_ENDPOINT_LOG_LEVEL_NAME = "API_USAGE" +_MAX_SEARCH = 5 + +def _find_available_level(start_level: int, max_level: int) -> int | None: + """Return the first unnamed level at or above logger's effective level. + + A level is considered unnamed if logging.getLevelName() returns the + default 'Level ' string that Python assigns to unregistered levels. + """ + for candidate in range(start_level, max_level): + if logging.getLevelName(candidate) == f"Level {candidate}": + return candidate + return None + +def _register_endpoint_log_level(logger: logging.Logger) -> int: + """Register API_USAGE at the first available level at or above the + logger's effective level. Returns the registered level.""" + level = _find_available_level(start_level = logger.getEffectiveLevel() + , max_level = logger.getEffectiveLevel()+_MAX_SEARCH) + if level is None: + effective = logger.getEffectiveLevel() + warnings.warn(message = f"Could not find an unnamed log level in range " + f"[{effective}, {effective + _MAX_SEARCH - 1}]. " + f"Falling back to the logger's effective level " + f" ({effective}) named '{logging.getLevelName(effective)}' ") + logger.critical(f"Endpoint usage log message will not be emitted with a level named " + f" '{_ENDPOINT_LOG_LEVEL_NAME}', but will be emitted as '{logging.getLevelName(effective)}'") + return effective + else: + logging.addLevelName(level, _ENDPOINT_LOG_LEVEL_NAME) + logger.info(f"Set the endpoint usage log level to " + f"{level}, emitting with the name {logging.getLevelName(level)}.") + return level + +class EndpointAuthorizer: + + def __init__(self, ahi:AuthHelper): + self.logger = logging.getLogger('gateway') + self.effective_endpoint_log_level = _register_endpoint_log_level(self.logger) + self._auth_functions = { + 'read': self._handle_read_auth, + 'create': self._handle_write_auth, + 'data-admin': self._handle_data_admin_auth, + 'pipeline-test': self._handle_pipeline_testing_auth + } + self._auth_helper_instance: AuthHelper = ahi + + # ------------------------------------------------------------------ + # Authorization handlers + # Each accepts a request and returns bool. + # Stubs - replace with real logic as handlers are expanded. + # ------------------------------------------------------------------ + + def _handle_read_auth(self, request_token) -> bool: + return self._auth_helper_instance.has_read_privs(request_token) + + def _handle_write_auth(self, request_token) -> bool: + return self._auth_helper_instance.has_write_privs(request_token) + + def _handle_data_admin_auth(self, request_token) -> bool: + return self._auth_helper_instance.has_data_admin_privs(request_token) + + def _handle_pipeline_testing_auth(self, request_token) -> bool: + return self._auth_helper_instance.has_pipeline_testing_privs(request_token) + + def _handle_unknown_auth(self, auth_type: str) -> bool: + raise ValueError(f"Authorization method '{auth_type}' not supported") + + # Indicate if the token parsed from the request is the secret, internal Globus token + def _is_secret_token(self, request_token) -> bool: + internal_token = self._auth_helper_instance.getProcessSecret() + + return internal_token == request_token + + # Pull the token from the Request + def _get_request_bearer_token(self, request) -> bool: + if 'Authorization' not in request.headers: + raise gwEx.GWNoAuthHeaderException('Missing Authorization header') + if not request.headers['Authorization'].upper().startswith('BEARER '): + raise gwEx.GWNoBearerSchemeException('Missing Bearer token') + return request.headers['Authorization'][6:].strip() + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def api_access_allowed(self, item, request) -> bool: + """Return True if the request is authorized to access the endpoint item. + + Access is granted unconditionally when: + - the item has no 'authorizer' key, or + - the request carries the Globus internal secret token. + + Otherwise, the appropriate handler for item['authorizer'] is invoked. + Raises ValueError if the authorizer type is unrecognized. + """ + self.logger.info("======Matched endpoint======") + self.logger.info(item) + + determination_log_msg = f"Authorization from api_access_allowed is'" \ + f" '_DETERMINATION_' for" \ + f" {request.headers.get("Host")}," \ + f" {request.headers.get("X-Original-Request-Method")}," \ + f" {request.headers.get("X-Original-URI")}." + + # If no authorizer is identified for the endpoint, access is allowed + if 'authorizer' not in item: + self.logger.debug(determination_log_msg.replace('_DETERMINATION_', f"{True}")) + return True + + try: + token = self._get_request_bearer_token(request=request) + except (gwEx.GWNoAuthHeaderException, gwEx.GWNoBearerSchemeException) as gwe: + self.logger.debug(f"Setting access_allowed_determination to False due to token problem:" + f" '{gwe.message}'") + access_allowed_determination = False + self.logger.debug(determination_log_msg.replace('_DETERMINATION_', f"{access_allowed_determination}")) + return access_allowed_determination + + # Allow access for any request presenting the secret, internal Globus token + if self._is_secret_token(token): + self.logger.debug(determination_log_msg.replace('_DETERMINATION_', f"{True}")) + return True + + auth_handler = self._auth_functions.get(item['authorizer']) + if auth_handler is None: + self.logger.error(f"Authorization method '{item['authorizer']}' configured for" + f" {request.headers.get("Host")}," + f" {request.headers.get("X-Original-Request-Method")}," + f" {request.headers.get("X-Original-URI")} " + f" is not supported.") + self._handle_unknown_auth(item['authorizer']) + access_allowed_determination = auth_handler(request_token=token) + if not isinstance(access_allowed_determination, bool): + self.logger.debug( f"Based on auth_handler returning " + f" '{access_allowed_determination}'" + f" setting access_allowed_determination to False.") + access_allowed_determination = False + self.logger.debug(determination_log_msg.replace('_DETERMINATION_', f"{access_allowed_determination}")) + return access_allowed_determination + + def log_auth_decision(self, response_code, response_length, auth_req_api, method, endpoint, client_ip, matched_pattern=None): + """ + Log authorization decision in Common Log Format. + + Logs each authorization check with details about the request and decision. + Format matches entity-api's after_request logging style. + + Args: + response_code: HTTP status code (200 for authorized, 401 for denied) + auth_req_api: The service/host making the request (e.g., "entity-api") + method: HTTP method (GET, POST, PUT, DELETE) + endpoint: The original endpoint being accessed (e.g., "/entities/abc123") + matched_pattern: The pattern that matched from api_endpoints.json (e.g., "/entities/<*>") + or special values: "NO_MATCH", "UNKNOWN_SERVICE", "MISSING_HEADERS" + """ + # Bail out immediately if the log statement about to be build would not be emitted. + if not self.logger.isEnabledFor(self.effective_endpoint_log_level): + return + + # Assume caller has extracted a single IP address using the X-Forwarded-For header, but + # switch to log a conventional '-' if it is not set. + client_ip = client_ip or '-' + + # Caller - not available without AWS IAM, use '-' + caller = '-' + + # User - not available at authorization time (token not yet validated) + # Could extract from Authorization header if needed, but use '-' for now + user = '-' + + # Request time in AWS/Apache format: [DD/MMM/YYYY:HH:MM:SS +0000] + request_time = datetime.now(timezone.utc).strftime('%d/%b/%Y:%H:%M:%S +0000') + + # HTTP method and resource path from parameters + # method and endpoint are passed as parameters (not from Flask request object) + # because they come from X-Original-* headers, not the /api_auth request itself + + # Protocol - assume HTTP/1.1 for auth requests + protocol = 'HTTP/1.1' + # + # # Response length - auth responses are small JSON messages (~34 bytes for 401, ~26 for 200) + # # Use approximate sizes or '-' + # response_length = 34 if response_code == 401 else 26 + + # Add matched pattern as additional info if available + # This helps understand which rule was applied + pattern_info = f"pattern={matched_pattern}" if matched_pattern else "" + + # Add the API originating the authorization as additional info need by API Usage reporting + authority_info = f"authority={auth_req_api}" if auth_req_api else "" + + # Format log message matching Common Log Format, aligned with these AWS API Gateway fields for + # custom access logs: + # $sourceIp $caller $user [$requestTime] "$method $resourcePath $protocol" $status $responseLength + log_message = ( + f'{client_ip}' # aligned with AWS API Gateway $sourceIp + f' {caller}' # aligned with AWS API Gateway $caller + f' {user}' # aligned with AWS API Gateway $user + f' [{request_time}]' # aligned with AWS API Gateway $requestTime + f' "{method} {endpoint} {protocol}"' # aligned with AWS API Gateway $method $resourcePath $protocol + f' {response_code}' # aligned with AWS API Gateway $status + f' {response_length}' + f' {pattern_info}' # Supplemental data for reporting, beyond Common Log Format + f' {authority_info}' # Supplemental data for reporting, beyond Common Log Format + ) + + self.logger.log(level=self.effective_endpoint_log_level + , msg=log_message) diff --git a/hubmap-auth/src/gateway_exceptions.py b/hubmap-auth/src/gateway_exceptions.py new file mode 100644 index 0000000..c8934cf --- /dev/null +++ b/hubmap-auth/src/gateway_exceptions.py @@ -0,0 +1,20 @@ +# Exceptions used internally by the service, typically for anticipated exceptions. +# Knowledge of Flask, HTTP codes, and formatting of the Response should be +# closer to the endpoint @app.route() methods rather than throughout service. +class GWConfigurationException(Exception): + """Exception raised when problems loading the service configuration are encountered.""" + def __init__(self, message='There were problems loading the configuration for the service.'): + self.message = message + super().__init__(self.message) + +class GWNoAuthHeaderException(Exception): + """Exception raised when authorization header is not present in the request.""" + def __init__(self, message='Missing Authorization header.'): + self.message = message + super().__init__(self.message) + +class GWNoBearerSchemeException(Exception): + """Exception raised when a bearer token is not present in the request.""" + def __init__(self, message='Missing Bearer token.'): + self.message = message + super().__init__(self.message) diff --git a/nginx/conf.d-localhost/hubmap-auth.conf b/nginx/conf.d-localhost/hubmap-auth.conf new file mode 100644 index 0000000..c6526c5 --- /dev/null +++ b/nginx/conf.d-localhost/hubmap-auth.conf @@ -0,0 +1,83 @@ +# Define the upstream hubmap-auth-server to be used by other API (on the same machine) nginx configs +# This server will be accessed via `http://hubmap-auth-server/api_auth` in other conf files +# We have to run the hubmap-auth service on a different local port to be used by other APIs +# when deployed with multiple sub-domains pointing to the same machine with same IP +upstream hubmap-auth-server { + server localhost:8000; +} + +# Main gateway server - Port 7777 (mapped to host in docker-compose.localhost.yml) +# This is the entry point for external requests to hubmap-auth +server { + # This port matches the docker-compose port mapping: "7777:7777" + listen 7777; + server_name localhost gateway.localhost.hubmapconsortium.org; + root /usr/share/nginx/html; + + # Logging to the mounted volume for outside container access + access_log /usr/src/app/log/nginx_access_gateway.log; + error_log /usr/src/app/log/nginx_error_gateway.log warn; + + location = /favicon.ico { + alias /usr/share/nginx/html/favicon.ico; + } + + # Pass requests to the uWSGI server using the "uwsgi" protocol on port 5000 + location / { + # Always enable CORS + # Response to preflight requests + if ($request_method = 'OPTIONS') { + # The directive `add_header` doesn't work when response status code is 401, 403 or 500 + # The `always` parameter is specified so the header field will be added regardless of the response code + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, OPTIONS' always; + + # Custom headers and headers various browsers should be OK with but aren't + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,Authorization, MAuthorization,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always; + + # Cache the response to this preflight request in browser for 24 hours (86400 seconds) + # without sending another preflight request + add_header 'Access-Control-Max-Age' 86400 always; + + add_header 'Content-Type' 'text/plain; charset=utf-8' always; + add_header 'Content-Length' 0 always; + return 204; + } + + # Response to the original requests (HTTP methods are case-sensitive) with CORS enabled + if ($request_method ~ (POST|GET|PUT)) { + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,Authorization, MAuthorization,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + } + + include uwsgi_params; + # Use IP v4 "127.0.0.1" instead of "localhost" to avoid 502 error caused by DNS failure + uwsgi_pass uwsgi://127.0.0.1:5000; + } +} + +# Internal hubmap-auth service (used by other APIs on localhost via upstream) +# Port 8000 is NOT exposed to host in docker-compose - it's only for internal container communication +server { + listen 8000; + server_name localhost; + root /usr/share/nginx/html; + + # We need this logging for inspecting auth requests from other internal services + # Logging to the mounted volume for outside container access + access_log /usr/src/app/log/nginx_access_hubmap-auth-server.log; + error_log /usr/src/app/log/nginx_error_hubmap-auth-server.log warn; + + location = /favicon.ico { + alias /usr/share/nginx/html/favicon.ico; + } + + # Pass requests to the uWSGI server using the "uwsgi" protocol on port 5000 + location / { + include uwsgi_params; + # Use IP v4 "127.0.0.1" instead of "localhost" to avoid 502 error caused by DNS failure + uwsgi_pass uwsgi://127.0.0.1:5000; + } +} \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..625ac17 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,155 @@ +# HubMap-Auth Test Suite + +This directory contains all tests for the hubmap-auth service, organized by test type and deployment environment. + +## Directory Structure + +``` +test/ +├── README.md # This file - test suite overview +├── localhost/ # Tests for localhost Docker deployment +│ ├── integration/ # Integration tests with other services +│ └── performance/ # Performance benchmarks (future) +└── [existing test files] # Other test types +``` + +## Test Categories + +### Localhost Tests (`localhost/`) + +Tests for hubmap-auth running in Docker Desktop for local development and proof-of-concept deployments. + +**When to run:** Before pushing changes that affect localhost deployment, Docker configuration, or authorization logic. + +**See:** [localhost/README.md](localhost/README.md) + +### Integration Tests (`localhost/integration/`) + +End-to-end tests verifying hubmap-auth integrates correctly with other APIs (entity-api, ingest-api, etc.) over the `gateway_hubmap` Docker network. + +**See:** [localhost/integration/README.md](localhost/integration/README.md) + +### Performance Tests (`localhost/performance/`) - Future + +Load testing and performance benchmarks for localhost deployment. + +## Quick Start + +### Run All Tests + +```bash +# Activate virtual environment +source .venv/bin/activate + +# Run all tests +python -m unittest discover -s test -v +``` + +### Run Localhost Integration Tests Only + +```bash +source .venv/bin/activate +python -m unittest discover -s test/localhost/integration -v +``` + +### Prerequisites + +1. **Docker containers running:** + ```bash + cd gateway + ./docker-localhost.sh start + docker ps | grep hubmap-auth # Should show "healthy" + ``` + +2. **Python virtual environment:**
+Tests use the same dependencies as the main application:
+✅ Uses the exact same requests version as the application
+✅ No version conflicts or drift
+✅ Simpler - one source of truth for dependencies
+✅ Tests run with the same environment as the app
+```bash +# Create virtual environment (first time only) +python3 -m venv .venv + +# Activate virtual environment +source .venv/bin/activate + +# Install application dependencies (includes requests) +pip install -r hubmap-auth/src/requirements.txt +``` + +## CI/CD Integration + +These tests are designed to run in GitHub Actions or similar CI/CD systems. Example workflow: + +```yaml +name: Localhost Integration Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Create Docker network + run: docker network create gateway_hubmap + + - name: Start hubmap-auth + run: | + cd gateway + ./docker-localhost.sh build + ./docker-localhost.sh start + + - name: Wait for healthy status + run: timeout 60 bash -c 'until docker ps | grep hubmap-auth | grep healthy; do sleep 2; done' + + - name: Install test dependencies + run: | + python -m venv .venv + source .venv/bin/activate + pip install -r test/localhost/integration/requirements.txt + + - name: Run integration tests + run: | + source .venv/bin/activate + python -m unittest discover -s test/localhost/integration -v +``` + +## Contributing + +When adding new tests: + +1. **Choose the right directory** - Place tests in the appropriate subdirectory based on type +2. **Follow existing patterns** - Match the style and structure of existing tests +3. **Add documentation** - Update relevant README files +4. **Keep tests independent** - Each test should run in isolation +5. **Use descriptive names** - Test names should clearly indicate what they verify +6. **Handle errors gracefully** - Provide actionable error messages + +## Test Execution Order + +Tests are discovered and run alphabetically by default. If execution order matters: + +1. Use `setUpClass` and `tearDownClass` for class-level setup +2. Use `setUp` and `tearDown` for test-level setup +3. Name test files to control discovery order if needed + +## Getting Help + +- **Test failures:** Check container logs with `docker logs hubmap-auth` +- **Connection errors:** Verify containers are running with `docker ps` +- **Import errors:** Ensure virtual environment is activated +- **Docker issues:** Check Docker Desktop is running + +## Related Documentation + +- [HubMap-Auth Deployment Guide](../README.md) +- [API Endpoints Configuration](../api_endpoints.localhost.json) +- [Docker Compose Configuration](../docker-compose.localhost.yml) diff --git a/tests/localhost/README.md b/tests/localhost/README.md new file mode 100644 index 0000000..d5682c8 --- /dev/null +++ b/tests/localhost/README.md @@ -0,0 +1,183 @@ +# Localhost Testing for HubMap-Auth + +This directory contains tests for hubmap-auth running in Docker Desktop on localhost. These tests verify the service works correctly in a local development/proof-of-concept environment before deployment to higher tiers (DEV, TEST, PROD). + +## Purpose + +Localhost tests serve multiple purposes: + +1. **Pre-deployment verification** - Validate configuration changes before pushing to DEV +2. **Local development** - Quick feedback during feature development +3. **Proof-of-concept** - Demonstrate hubmap-auth integration with new APIs +4. **Regression testing** - Ensure changes don't break existing functionality + +## Test Types + +### Integration Tests (`integration/`) + +End-to-end tests that verify hubmap-auth integrates correctly with other services over Docker networking. + +**What they test:** +- Container startup and health +- Authorization logic with `api_endpoints.localhost.json` +- Communication with other APIs (entity-api, etc.) +- nginx auth_request flow +- Docker network connectivity + +**See:** [integration/README.md](integration/README.md) + +### Performance Tests (`performance/`) - Future + +Benchmarks and load tests for localhost deployment. + +**What they will test:** +- Response time under load +- Concurrent request handling +- Memory usage patterns +- Container resource limits + +## Prerequisites + +### 1. Docker Setup + +Create the shared Docker network (one-time setup): +```bash +docker network create gateway_hubmap +``` + +### 2. Build and Start Containers + +```bash +cd gateway +./docker-localhost.sh build +./docker-localhost.sh start + +# Verify containers are healthy +docker ps +# hubmap-auth STATUS should show "healthy" +``` + +### 3. Python Environment +Tests use the same dependencies as the main application:
+✅ Uses the exact same requests version as the application
+✅ No version conflicts or drift
+✅ Simpler - one source of truth for dependencies
+✅ Tests run with the same environment as the app
+```bash +# Create virtual environment (first time only) +python3 -m venv .venv + +# Activate virtual environment +source .venv/bin/activate + +# Install application dependencies (includes requests) +pip install -r hubmap-auth/src/requirements.txt +``` + +# HTTP client library for API testing +``` +## Running Tests + +### All Localhost Tests + +```bash +source .venv/bin/activate +python -m unittest discover -s test/localhost -v +``` + +### Integration Tests Only + +```bash +source .venv/bin/activate +python -m unittest discover -s test/localhost/integration -v +``` + +### Specific Test File + +```bash +source .venv/bin/activate +python -m unittest test.localhost.integration.test_localhost_integration -v +``` + +## Environment Differences + +Localhost deployment differs from higher tiers in several ways: + +| Aspect | Localhost | DEV/TEST/PROD | +|--------|-----------|---------------| +| SSL/TLS | Disabled | Let's Encrypt certificates | +| Ports | 7777 (custom) | 80, 443 (standard) | +| Logging | Local files + Docker logs | CloudWatch Logs | +| Auth endpoints | `api_endpoints.localhost.json` | `api_endpoints.{dev\|test\|prod}.json` | +| Network | `gateway_hubmap` (Docker) | AWS VPC | +| Service discovery | Docker DNS | AWS Route53/ELB | + +Tests in this directory account for these differences. + +## Debugging Failed Tests + +### Container Not Running + +```bash +# Check container status +docker ps -a | grep hubmap-auth + +# Check logs +docker logs hubmap-auth + +# Restart if needed +cd gateway +./docker-localhost.sh down +./docker-localhost.sh start +``` + +### Container Not Healthy + +```bash +# Check health status +docker inspect hubmap-auth | grep -A 10 Health + +# Common causes: +# - Port 7777 already in use +# - nginx configuration error +# - Missing api_endpoints.localhost.json +``` + +### Connection Refused + +```bash +# Verify port mapping +docker port hubmap-auth + +# Test from host +curl http://localhost:7777/status.json + +# Test from inside container +docker exec hubmap-auth curl http://localhost:7777/status.json +``` + +### Docker Network Issues + +```bash +# Inspect network +docker network inspect gateway_hubmap + +# Verify containers are on the network +docker network inspect gateway_hubmap | grep Name +``` + +## Adding New Test Types + +When adding new test categories: + +1. **Create subdirectory** under `test/localhost/` +2. **Add README.md** explaining the test type and how to run +3. **Add requirements.txt** if new dependencies needed +4. **Update this README** to document the new test type +5. **Follow best practices** from existing integration tests + +## Related Documentation + +- [Parent Test Suite Overview](../README.md) +- [Docker Localhost Deployment](../../README.md) +- [API Endpoints Configuration](../../api_endpoints.localhost.json) diff --git a/tests/localhost/integration/README.md b/tests/localhost/integration/README.md new file mode 100644 index 0000000..911ebf5 --- /dev/null +++ b/tests/localhost/integration/README.md @@ -0,0 +1,240 @@ +# HubMap-Auth Localhost Integration Tests + +Integration tests for hubmap-auth localhost deployment. These tests verify that hubmap-auth runs correctly in a Docker container and provides authorization services to other APIs on the `gateway_hubmap` Docker network. + +## Prerequisites + +### Running Containers +The tests require hubmap-auth to be running in Docker: + +```bash +cd gateway +./docker-localhost.sh build +./docker-localhost.sh start + +# Verify the container is healthy +docker ps | grep hubmap-auth +# STATUS should show "healthy" +``` + +### Python Environment +Tests are designed to run in a Python virtual environment: + +```bash +# Create virtual environment (first time only) +python3 -m venv .venv + +# Activate virtual environment +source .venv/bin/activate + +# Install dependencies (first time only) +pip install requests +``` + +## Running the Tests + +### From gateway repository root + +```bash +# Activate virtual environment +source .venv/bin/activate + +# Run all localhost integration tests +python -m unittest discover -s test/localhost/integration -p "test_*.py" -v +``` + +### Run specific test file + +```bash +source .venv/bin/activate +python -m unittest discover -s test/localhost/integration -p "test_localhost_integration.py" -v +``` + +### Run Specific Test Classes + +```bash +source .venv/bin/activate +python -m unittest test.localhost.integration.test_localhost_integration.HubMapAuthLocalhostTests -v +python -m unittest test.localhost.integration.test_localhost_integration.HubMapAuthEndpointCoverage -v +``` + +### Run Individual Tests + +```bash +source .venv/bin/activate +python -m unittest test.localhost.integration.test_localhost_integration.HubMapAuthLocalhostTests.test_status_endpoint_responds -v +``` + +### Run with Verbose Output + +Add `-v` flag for detailed output: + +```bash +python -m unittest discover -s test -p "test_localhost_integration.py" -v +``` + +## Test Structure + +### Test Classes + +**HubMapAuthLocalhostTests** +- Core functionality tests for hubmap-auth +- Validates status endpoint responds correctly +- Tests `/api_auth` endpoint with various header combinations +- Validates performance requirements (< 1 second response) + +**HubMapAuthEndpointCoverage** +- Tests authorization logic against `api_endpoints.localhost.json` +- Verifies public endpoints are accessible without authentication +- Verifies protected endpoints require authentication +- Uses parameterized subtests for comprehensive coverage + +### Key Test Methods + +- `test_status_endpoint_responds()` - Basic connectivity and response validation +- `test_api_auth_with_valid_headers_public_endpoint()` - Public endpoint authorization +- `test_api_auth_with_valid_headers_protected_endpoint_no_token()` - Protected endpoint blocking +- `test_api_endpoints_json_valid_format()` - Configuration file validation + +## Best Practices Used + +### Code Quality +- **Type hints** - All parameters and return types annotated for clarity +- **Docstrings** - Every test has descriptive documentation +- **Descriptive names** - Test names clearly describe what they verify +- **Proper assertions** - Meaningful assertion messages for failures + +### Test Organization +- **Class-level constants** - `BASE_URL`, `TIMEOUT` defined once and reused +- **setUpClass** - Expensive setup (container checks) run once per class +- **subTest** - Parameterized tests provide clear failure reporting per endpoint +- **Focused tests** - Each test validates one specific behavior + +### Robustness +- **Timeout handling** - All requests have explicit timeouts +- **Connection error handling** - Graceful failure with helpful messages +- **Conditional skipping** - Tests skip gracefully when Docker unavailable +- **Clear error messages** - Failures indicate exactly what went wrong and how to fix + +### CI/CD Ready +- **No external dependencies** - Uses only standard library + requests +- **Subprocess isolation** - Docker commands use subprocess with timeout +- **Exit codes** - Proper test success/failure reporting +- **Environment agnostic** - Works in local development and CI pipelines + +## Test Coverage + +### What These Tests Verify + +✅ hubmap-auth container starts and becomes healthy +✅ Status endpoint responds with valid JSON +✅ `/api_auth` endpoint validates required headers +✅ Authorization logic checks `api_endpoints.localhost.json` +✅ Public endpoints allow access without authentication +✅ Protected endpoints block access without authentication +✅ Unknown services are rejected +✅ Missing headers are properly handled +✅ Configuration file has valid JSON structure + +### What These Tests Don't Cover + +❌ Token validation with real Globus tokens (requires valid credentials) +❌ Group membership validation (requires test users in specific groups) +❌ Load testing / performance under stress +❌ Security penetration testing +❌ Multi-container orchestration failures + +## Troubleshooting + +### "Cannot connect to hubmap-auth" +**Cause:** Container not running or not accessible +**Solution:** +```bash +cd gateway +./docker-localhost.sh start +docker ps | grep hubmap-auth +``` + +### "hubmap-auth not ready: status.json returned 500" +**Cause:** Container running but application not initialized +**Solution:** Check container logs for errors +```bash +docker logs hubmap-auth +``` + +### "Docker not available - skipping test" +**Cause:** Docker commands failing or timing out +**Solution:** Verify Docker is running +```bash +docker --version +docker ps +``` + +### Tests hang or timeout +**Cause:** Network connectivity issues between host and containers +**Solution:** Verify port mappings and container networking +```bash +docker port hubmap-auth +curl http://localhost:7777/status.json +``` + +## Future Enhancements + +### Pytest Migration (Optional) +While these tests use Python's built-in `unittest`, you can optionally migrate to pytest for additional features: + +**Benefits of pytest:** +- More concise syntax with simple `assert` statements +- Better parameterized testing with `@pytest.mark.parametrize` +- Richer output formatting and failure reporting +- Extensive plugin ecosystem (coverage, parallel execution, etc.) +- Fixture system for complex setup/teardown + +**Migration effort:** Low - pytest runs unittest tests without modification + +**To use pytest (optional):** +```bash +pip install pytest +pytest test/test_localhost_integration.py -v +``` + +**Recommendation:** Stick with unittest for now unless you need pytest-specific features. Unittest is part of Python's standard library and sufficient for these integration tests. + +## Contributing + +When adding new tests: + +1. **Follow existing patterns** - Use the same class structure and naming conventions +2. **Add docstrings** - Every test should explain what it validates +3. **Use subTest for parameters** - When testing multiple similar cases +4. **Handle failures gracefully** - Provide actionable error messages +5. **Keep tests independent** - Each test should work in isolation +6. **Update this README** - Document new test classes or significant changes + +## CI/CD Integration + +These tests are designed to run in GitHub Actions or similar CI/CD systems: + +```yaml +# Example GitHub Actions workflow +- name: Start hubmap-auth + run: | + cd gateway + ./docker-localhost.sh build + ./docker-localhost.sh start + +- name: Wait for healthy status + run: | + timeout 60 bash -c 'until docker ps | grep hubmap-auth | grep healthy; do sleep 2; done' + +- name: Run integration tests + run: | + source .venv/bin/activate + python -m unittest discover -s test -p "test_localhost_integration.py" -v +``` + +## Related Documentation + +- [Docker Deployment Guide](../README.md) - How to deploy hubmap-auth locally +- [API Endpoints Configuration](../api_endpoints.localhost.json) - Authorization configuration +- [HubMap-Auth API Documentation](../hubmap-auth/README.md) - API reference diff --git a/tests/localhost/integration/gateway_test_readme.md b/tests/localhost/integration/gateway_test_readme.md new file mode 100644 index 0000000..5bdc50f --- /dev/null +++ b/tests/localhost/integration/gateway_test_readme.md @@ -0,0 +1,268 @@ +# HubMap-Auth Localhost Integration Tests + +Integration tests for hubmap-auth localhost deployment. These tests verify that hubmap-auth runs correctly in a Docker container and provides authorization services to other APIs on the `gateway_hubmap` Docker network. + +## Test Files + +This directory contains tests organized by functionality: + +- **test_endpoints_public.py** - Public endpoints (no auth required) +- **test_endpoints_protected.py** - Protected endpoints (auth required) +- **test_authorization.py** - Authorization logic (/api_auth behavior) +- **test_configuration.py** - Configuration file validation +- **test_cors.py** - CORS headers and preflight +- **test_cache.py** - Cache management endpoints + +Files are named to group together alphabetically by purpose (all `test_endpoints_*` files group together, etc.). + +## Prerequisites + +### Running Containers +The tests require hubmap-auth to be running in Docker: + +```bash +cd gateway +./docker-localhost.sh build +./docker-localhost.sh start + +# Verify the container is healthy +docker ps | grep hubmap-auth +# STATUS should show "healthy" +``` + +### Python Environment +Tests are designed to run in a Python virtual environment: + +```bash +# Create virtual environment (first time only) +python3 -m venv .venv + +# Activate virtual environment +source .venv/bin/activate + +# Install dependencies (first time only) +pip install requests +``` + +## Running the Tests + +### From gateway repository root + +```bash +# Activate virtual environment +source .venv/bin/activate + +# Run all localhost integration tests +python -m unittest discover -s test/localhost/integration -p "test_*.py" -v +``` + +### Run specific test file + +```bash +source .venv/bin/activate + +# Run all public endpoint tests +python -m unittest tests.localhost.integration.test_endpoints_public -v + +# Run all protected endpoint tests +python -m unittest tests.localhost.integration.test_endpoints_protected -v + +# Run all authorization tests +python -m unittest tests.localhost.integration.test_authorization -v +``` + +### Run Specific Test Classes + +```bash +source .venv/bin/activate + +# Run just public GET endpoint tests +python -m unittest tests.localhost.integration.test_endpoints_public.EndpointsGETPublicTests -v + +# Run just protected DELETE endpoint tests +python -m unittest tests.localhost.integration.test_endpoints_protected.EndpointsDELETEProtectedTests -v + +# Run just authorization header validation tests +python -m unittest tests.localhost.integration.test_authorization.ApiAuthHeaderValidationTests -v +``` + +### Run Individual Tests + +```bash +source .venv/bin/activate +python -m unittest tests.localhost.integration.test_endpoints_public.EndpointsGETPublicTests.test_status_json_responds -v +``` + +### Run with Verbose Output + +Add `-v` flag for detailed output: + +```bash +python -m unittest discover -s test -p "test_localhost_integration.py" -v +``` + +## Test Structure + +### Test Classes + +**HubMapAuthLocalhostTests** +- Core functionality tests for hubmap-auth +- Validates status endpoint responds correctly +- Tests `/api_auth` endpoint with various header combinations +- Validates performance requirements (< 1 second response) + +**HubMapAuthEndpointCoverage** +- Tests authorization logic against `api_endpoints.localhost.json` +- Verifies public endpoints are accessible without authentication +- Verifies protected endpoints require authentication +- Uses parameterized subtests for comprehensive coverage + +### Key Test Methods + +- `test_status_endpoint_responds()` - Basic connectivity and response validation +- `test_api_auth_with_valid_headers_public_endpoint()` - Public endpoint authorization +- `test_api_auth_with_valid_headers_protected_endpoint_no_token()` - Protected endpoint blocking +- `test_api_endpoints_json_valid_format()` - Configuration file validation + +## Best Practices Used + +### Code Quality +- **Type hints** - All parameters and return types annotated for clarity +- **Docstrings** - Every test has descriptive documentation +- **Descriptive names** - Test names clearly describe what they verify +- **Proper assertions** - Meaningful assertion messages for failures + +### Test Organization +- **Class-level constants** - `BASE_URL`, `TIMEOUT` defined once and reused +- **setUpClass** - Expensive setup (container checks) run once per class +- **subTest** - Parameterized tests provide clear failure reporting per endpoint +- **Focused tests** - Each test validates one specific behavior + +### Robustness +- **Timeout handling** - All requests have explicit timeouts +- **Connection error handling** - Graceful failure with helpful messages +- **Conditional skipping** - Tests skip gracefully when Docker unavailable +- **Clear error messages** - Failures indicate exactly what went wrong and how to fix + +### CI/CD Ready +- **No external dependencies** - Uses only standard library + requests +- **Subprocess isolation** - Docker commands use subprocess with timeout +- **Exit codes** - Proper test success/failure reporting +- **Environment agnostic** - Works in local development and CI pipelines + +## Test Coverage + +### What These Tests Verify + +✅ hubmap-auth container starts and becomes healthy +✅ Status endpoint responds with valid JSON +✅ `/api_auth` endpoint validates required headers +✅ Authorization logic checks `api_endpoints.localhost.json` +✅ Public endpoints allow access without authentication +✅ Protected endpoints block access without authentication +✅ Unknown services are rejected +✅ Missing headers are properly handled +✅ Configuration file has valid JSON structure + +### What These Tests Don't Cover + +❌ Token validation with real Globus tokens (requires valid credentials) +❌ Group membership validation (requires test users in specific groups) +❌ Load testing / performance under stress +❌ Security penetration testing +❌ Multi-container orchestration failures + +## Troubleshooting + +### "Cannot connect to hubmap-auth" +**Cause:** Container not running or not accessible +**Solution:** +```bash +cd gateway +./docker-localhost.sh start +docker ps | grep hubmap-auth +``` + +### "hubmap-auth not ready: status.json returned 500" +**Cause:** Container running but application not initialized +**Solution:** Check container logs for errors +```bash +docker logs hubmap-auth +``` + +### "Docker not available - skipping test" +**Cause:** Docker commands failing or timing out +**Solution:** Verify Docker is running +```bash +docker --version +docker ps +``` + +### Tests hang or timeout +**Cause:** Network connectivity issues between host and containers +**Solution:** Verify port mappings and container networking +```bash +docker port hubmap-auth +curl http://localhost:7777/status.json +``` + +## Future Enhancements + +### Pytest Migration (Optional) +While these tests use Python's built-in `unittest`, you can optionally migrate to pytest for additional features: + +**Benefits of pytest:** +- More concise syntax with simple `assert` statements +- Better parameterized testing with `@pytest.mark.parametrize` +- Richer output formatting and failure reporting +- Extensive plugin ecosystem (coverage, parallel execution, etc.) +- Fixture system for complex setup/teardown + +**Migration effort:** Low - pytest runs unittest tests without modification + +**To use pytest (optional):** +```bash +pip install pytest +pytest test/test_localhost_integration.py -v +``` + +**Recommendation:** Stick with unittest for now unless you need pytest-specific features. Unittest is part of Python's standard library and sufficient for these integration tests. + +## Contributing + +When adding new tests: + +1. **Follow existing patterns** - Use the same class structure and naming conventions +2. **Add docstrings** - Every test should explain what it validates +3. **Use subTest for parameters** - When testing multiple similar cases +4. **Handle failures gracefully** - Provide actionable error messages +5. **Keep tests independent** - Each test should work in isolation +6. **Update this README** - Document new test classes or significant changes + +## CI/CD Integration + +These tests are designed to run in GitHub Actions or similar CI/CD systems: + +```yaml +# Example GitHub Actions workflow +- name: Start hubmap-auth + run: | + cd gateway + ./docker-localhost.sh build + ./docker-localhost.sh start + +- name: Wait for healthy status + run: | + timeout 60 bash -c 'until docker ps | grep hubmap-auth | grep healthy; do sleep 2; done' + +- name: Run integration tests + run: | + source .venv/bin/activate + python -m unittest discover -s test -p "test_localhost_integration.py" -v +``` + +## Related Documentation + +- [Docker Deployment Guide](../README.md) - How to deploy hubmap-auth locally +- [API Endpoints Configuration](../api_endpoints.localhost.json) - Authorization configuration +- [HubMap-Auth API Documentation](../hubmap-auth/README.md) - API reference diff --git a/tests/localhost/integration/test_endpoints_protected.py b/tests/localhost/integration/test_endpoints_protected.py new file mode 100644 index 0000000..7e865e3 --- /dev/null +++ b/tests/localhost/integration/test_endpoints_protected.py @@ -0,0 +1,258 @@ +""" +Tests for protected hubmap-auth endpoints requiring authentication. + +Protected endpoints should block access without valid tokens and proper +group membership. These tests verify authorization enforcement. + +Run all protected endpoint tests: + python -m unittest tests.localhost.integration.test_endpoints_protected -v + +Run specific HTTP method tests: + python -m unittest tests.localhost.integration.test_endpoints_protected.EndpointsGETProtectedTests -v + python -m unittest tests.localhost.integration.test_endpoints_protected.EndpointsPOSTProtectedTests -v +""" + +import unittest +import requests + + +class EndpointsGETProtectedTests(unittest.TestCase): + """Test protected GET endpoints that require authentication.""" + + BASE_URL = "http://localhost:7777" + TIMEOUT = 10 + + def test_usergroups_blocks_without_token(self): + """Test that GET /usergroups requires authentication via entity-api.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/usergroups", + "X-Original-Request-Method": "GET" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + def test_datasets_unpublished_blocks_without_token(self): + """Test that GET /datasets/unpublished requires authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/datasets/unpublished", + "X-Original-Request-Method": "GET" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + def test_descendants_blocks_without_token(self): + """Test that GET /descendants/ requires authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/descendants/test-id", + "X-Original-Request-Method": "GET" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + def test_children_blocks_without_token(self): + """Test that GET /children/ requires authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/children/test-id", + "X-Original-Request-Method": "GET" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + def test_previous_revisions_blocks_without_token(self): + """Test that GET /previous_revisions/ requires authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/previous_revisions/test-id", + "X-Original-Request-Method": "GET" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + def test_next_revisions_blocks_without_token(self): + """Test that GET /next_revisions/ requires authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/next_revisions/test-id", + "X-Original-Request-Method": "GET" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + +class EndpointsPOSTProtectedTests(unittest.TestCase): + """Test protected POST endpoints that require authentication.""" + + BASE_URL = "http://localhost:7777" + TIMEOUT = 10 + + def test_datasets_components_blocks_without_token(self): + """Test that POST /datasets/components requires authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/datasets/components", + "X-Original-Request-Method": "POST" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + def test_entities_multiple_samples_blocks_without_token(self): + """Test that POST /entities/multiple-samples/ requires authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/entities/multiple-samples/5", + "X-Original-Request-Method": "POST" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + def test_entities_create_blocks_without_token(self): + """Test that POST /entities/ requires authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/entities/sample", + "X-Original-Request-Method": "POST" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + +class EndpointsPUTProtectedTests(unittest.TestCase): + """Test protected PUT endpoints that require authentication.""" + + BASE_URL = "http://localhost:7777" + TIMEOUT = 10 + + def test_entities_update_blocks_without_token(self): + """Test that PUT /entities/ requires authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/entities/test-uuid", + "X-Original-Request-Method": "PUT" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + def test_datasets_retract_blocks_without_token(self): + """Test that PUT /datasets//retract requires admin authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/datasets/test-id/retract", + "X-Original-Request-Method": "PUT" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + # Requires Data Admin group + self.assertEqual(response.status_code, 401) + + +class EndpointsDELETEProtectedTests(unittest.TestCase): + """Test protected DELETE endpoints that require authentication.""" + + BASE_URL = "http://localhost:7777" + TIMEOUT = 10 + + def test_flush_cache_blocks_without_token(self): + """Test that DELETE /flush-cache/ requires authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/flush-cache/test-id", + "X-Original-Request-Method": "DELETE" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + self.assertEqual(response.status_code, 401) + + def test_flush_all_cache_blocks_without_token(self): + """Test that DELETE /flush-all-cache requires admin authentication.""" + headers = { + "Host": "entity-api", + "X-Original-URI": "/flush-all-cache", + "X-Original-Request-Method": "DELETE" + } + + response = requests.get( + f"{self.BASE_URL}/api_auth", + headers=headers, + timeout=self.TIMEOUT + ) + + # Requires Data Admin group + self.assertEqual(response.status_code, 401) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/localhost/integration/test_public_endpoints.py b/tests/localhost/integration/test_public_endpoints.py new file mode 100644 index 0000000..0dd028a --- /dev/null +++ b/tests/localhost/integration/test_public_endpoints.py @@ -0,0 +1,105 @@ +""" +Tests for public hubmap-auth endpoints accessible without authentication. + +Public endpoints should be accessible to anyone without requiring a token. +These include status checks, informational endpoints, and read-only data. + +Run all public endpoint tests: + python -m unittest tests.localhost.integration.test_endpoints_public -v + +Run just GET tests: + python -m unittest tests.localhost.integration.test_endpoints_public.EndpointsGETPublicTests -v + +Run just POST tests: + python -m unittest tests.localhost.integration.test_endpoints_public.EndpointsPOSTPublicTests -v +""" + +import unittest +import requests +from requests.exceptions import ConnectionError + + +class EndpointsGETPublicTests(unittest.TestCase): + """Test public GET endpoints that don't require authentication.""" + + BASE_URL = "http://localhost:7777" + TIMEOUT = 10 + + @classmethod + def setUpClass(cls): + """Verify hubmap-auth is accessible before running tests.""" + try: + response = requests.get(f"{cls.BASE_URL}/status.json", timeout=cls.TIMEOUT) + if response.status_code != 200: + raise RuntimeError( + f"hubmap-auth not ready: status.json returned {response.status_code}" + ) + except ConnectionError as e: + raise RuntimeError( + f"Cannot connect to hubmap-auth at {cls.BASE_URL}. " + "Ensure container is running: cd gateway && ./docker-localhost.sh start" + ) from e + + def test_status_json_responds(self): + """Test that /status.json endpoint returns valid status.""" + response = requests.get(f"{self.BASE_URL}/status.json", timeout=self.TIMEOUT) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "application/json") + + data = response.json() + self.assertIsInstance(data, dict) + # Verify gateway section exists with version info + self.assertIn("gateway", data) + self.assertIn("version", data["gateway"]) + + def test_status_json_performance(self): + """Test that status endpoint responds within reasonable time.""" + response = requests.get(f"{self.BASE_URL}/status.json", timeout=self.TIMEOUT) + + # Multi-service status check may take longer than simple endpoints + # Allow up to 3 seconds for comprehensive health check + self.assertLess(response.elapsed.total_seconds(), 3.0) + + def test_status_json_includes_service_status(self): + """Test that status includes information about dependent services.""" + response = requests.get(f"{self.BASE_URL}/status.json", timeout=self.TIMEOUT) + + data = response.json() + # Should include status for various services + self.assertIsInstance(data, dict) + # At minimum should have gateway info + self.assertGreater(len(data), 0) + + def test_status_json_valid_content_type(self): + """Test that status endpoint returns proper JSON content type.""" + response = requests.get(f"{self.BASE_URL}/status.json", timeout=self.TIMEOUT) + + self.assertIn("application/json", response.headers.get("Content-Type", "")) + + def test_status_json_handles_errors_gracefully(self): + """Test that status endpoint handles malformed requests gracefully.""" + # Request with invalid query parameters + response = requests.get( + f"{self.BASE_URL}/status.json", + params={"invalid": "param"}, + timeout=self.TIMEOUT + ) + + # Should still return 200 (ignore unknown params) or proper error code + self.assertIn(response.status_code, [200, 400]) + + +class EndpointsPOSTPublicTests(unittest.TestCase): + """Test public POST endpoints that don't require authentication.""" + + BASE_URL = "http://localhost:7777" + TIMEOUT = 10 + + # Placeholder for future public POST endpoints + # Currently hubmap-auth has no public POST endpoints + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_get_status_info.py b/tests/test_get_status_info.py deleted file mode 100644 index 2252a3d..0000000 --- a/tests/test_get_status_info.py +++ /dev/null @@ -1,104 +0,0 @@ -import json -from http import HTTPStatus -from unittest.mock import patch, MagicMock -from app import _get_status_info -import requests - -def make_response(status, headers, text): - mock = MagicMock() - mock.status_code = status - mock.headers = headers - mock.text = text - def json_loader(): - return json.loads(text) - mock.json.side_effect = json_loader - return mock - -@patch("requests.get") -def test_json_response(mock_get): - mock_get.return_value = make_response( - status=200, - headers={"Content-Type": "application/json"}, - text='{"service": "ok"}' - ) - - result = _get_status_info("http://example.com") - assert result == {"service": "ok"} - -@patch("requests.get") -def test_text_html_with_json(mock_get): - mock_get.return_value = make_response( - status=200, - headers={"Content-Type": "text/html"}, - text='{"service": "ok"}' - ) - - result = _get_status_info("http://example.com") - assert result == {"service": "ok"} - -@patch("requests.get") -def test_text_plain_with_json(mock_get): - mock_get.return_value = make_response( - status=200, - headers={"Content-Type": "text/plain"}, - text='{"a": 1}' - ) - - result = _get_status_info("http://example.com") - assert result == {"a": 1} - -@patch("requests.get") -def test_text_body_ok(mock_get): - mock_get.return_value = make_response( - status=200, - headers={"Content-Type": "text/plain"}, - text='ok' - ) - - result = _get_status_info("http://example.com") - assert result == {"text": "ok"} - -@patch("requests.get") -def test_text_non_json_unrecognized(mock_get): - mock_get.return_value = make_response( - status=200, - headers={"Content-Type": "text/plain"}, - text='Not JSON content' - ) - - result = _get_status_info("http://example.com") - assert result == { - "error": "Unexpected response text 'Not JSON content' for HTTP 200" - } - -@patch("requests.get") -def test_unsupported_content_type(mock_get): - mock_get.return_value = make_response( - status=200, - headers={"Content-Type": "application/xml"}, - text='' - ) - - result = _get_status_info("http://example.com") - assert result == {"error": "Unable to determine status from header content type"} - -@patch("requests.get") -def test_connect_timeout(mock_get): - mock_get.side_effect = requests.exceptions.ConnectTimeout() - - result = _get_status_info("http://example.com") - assert result == {"connection_timeout": True, "read_timeout": None} - -@patch("requests.get") -def test_read_timeout(mock_get): - mock_get.side_effect = requests.exceptions.ReadTimeout() - - result = _get_status_info("http://example.com") - assert result == {"connection_timeout": False, "read_timeout": True} - -@patch("requests.get") -def test_generic_request_exception(mock_get): - mock_get.side_effect = requests.exceptions.RequestException("Network issue") - - result = _get_status_info("http://example.com") - assert result == {"error": "Network issue"}