334 lines
11 KiB
Bash
Executable File
334 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
|
env_file=${1:-}
|
|
expected_domain=${2:-}
|
|
|
|
if [[ -z "$env_file" || -z "$expected_domain" || ! -f "$env_file" ]]; then
|
|
echo "Usage: $0 ENV_FILE EXPECTED_DOMAIN" >&2
|
|
exit 1
|
|
fi
|
|
|
|
[[ "$(stat -c '%a' "$env_file")" == 600 ]] || {
|
|
echo "Test environment must have mode 0600: $env_file" >&2
|
|
exit 1
|
|
}
|
|
[[ "$(stat -c '%u' "$env_file")" == "$(id -u)" ]] || {
|
|
echo "Test environment must be owned by the current operator." >&2
|
|
exit 1
|
|
}
|
|
|
|
read_value() {
|
|
local key=$1
|
|
awk -v key="$key" '
|
|
index($0, key "=") == 1 {
|
|
print substr($0, length(key) + 2)
|
|
found = 1
|
|
exit
|
|
}
|
|
END { if (!found) exit 1 }
|
|
' "$env_file"
|
|
}
|
|
|
|
require_value() {
|
|
local key=$1 value
|
|
value=$(read_value "$key") || true
|
|
[[ -n "$value" ]] || {
|
|
echo "$key is missing or empty in $env_file." >&2
|
|
exit 1
|
|
}
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
valid_fcm_service_account_json() {
|
|
jq -e '
|
|
.type == "service_account" and
|
|
(.project_id | type == "string" and length > 0) and
|
|
(.client_email | type == "string" and length > 0) and
|
|
(.private_key | type == "string" and length > 0)
|
|
' >/dev/null 2>&1
|
|
}
|
|
|
|
valid_nonempty_csv() {
|
|
local item compact
|
|
local -a items
|
|
|
|
IFS=',' read -r -a items <<<"$1"
|
|
[[ ${#items[@]} -gt 0 ]] || return 1
|
|
|
|
for item in "${items[@]}"; do
|
|
compact=${item//[[:space:]]/}
|
|
[[ -n "$compact" ]] || return 1
|
|
done
|
|
}
|
|
|
|
[[ "$(require_value DEPLOYMENT_ENV)" == test ]] || {
|
|
echo "Test validation requires DEPLOYMENT_ENV=test." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value DEPLOYMENT_TARGET)" == compose ]] || {
|
|
echo "Test validation requires DEPLOYMENT_TARGET=compose." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value COMPOSE_PROJECT_NAME)" == who_need_help_test ]] || {
|
|
echo "The test checkout must use COMPOSE_PROJECT_NAME=who_need_help_test." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value APP_IMAGE)" == who-need-help:test-* ]] || {
|
|
echo "The test checkout must use a test-specific APP_IMAGE." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value SOCKET_PROXY_IMAGE)" == who-need-help:socket-proxy-test-* ]] || {
|
|
echo "The test checkout must use a test-specific socket-proxy image." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value POSTGIS_IMAGE)" == who-need-help:postgis-test-* ]] || {
|
|
echo "The test checkout must use a test-specific PostGIS image." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value DATABASE_MODE)" == container ]] || {
|
|
echo "The test checkout must use its project-owned database container." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value POSTGRES_DB)" == who_need_help_test ]] || {
|
|
echo "The test database must be named who_need_help_test." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value DATABASE_URL)" == ecto://*"@db/who_need_help_test" ]] || {
|
|
echo "The test DATABASE_URL must target its own Compose database." >&2
|
|
exit 1
|
|
}
|
|
email_delivery_provider=$(require_value EMAIL_DELIVERY_PROVIDER)
|
|
case "$email_delivery_provider" in
|
|
smtp)
|
|
smtp_relay=$(require_value SMTP_RELAY)
|
|
if [[ "$smtp_relay" != mailpit ]]; then
|
|
require_value SMTP_PORT >/dev/null
|
|
smtp_auth=$(require_value SMTP_AUTH)
|
|
smtp_username=$(read_value SMTP_USERNAME 2>/dev/null || true)
|
|
smtp_password=$(read_value SMTP_PASSWORD 2>/dev/null || true)
|
|
|
|
[[ "$smtp_auth" != always || (-n "$smtp_username" && -n "$smtp_password") ]] || {
|
|
echo "External test SMTP requires SMTP_USERNAME and SMTP_PASSWORD when SMTP_AUTH=always." >&2
|
|
exit 1
|
|
}
|
|
[[ (-z "$smtp_username" && -z "$smtp_password") ||
|
|
(-n "$smtp_username" && -n "$smtp_password") ]] || {
|
|
echo "SMTP_USERNAME and SMTP_PASSWORD must be configured together." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
;;
|
|
|
|
*)
|
|
echo "EMAIL_DELIVERY_PROVIDER must be smtp." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
require_value EMAIL_FROM_ADDRESS >/dev/null
|
|
[[ "$(require_value PHX_HOST)" == "$expected_domain" &&
|
|
"$(require_value WNH_BASE_URL)" == "https://$expected_domain" ]] || {
|
|
echo "The test public origin does not match EXPECTED_DOMAIN." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value PUBLIC_UPSTREAM_NAME)" == who-need-help-test ]] || {
|
|
echo "The test public upstream alias must be who-need-help-test." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value PUBLIC_ROUTE_ID)" == who_need_help_test ]] || {
|
|
echo "The test route ID must be who_need_help_test." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value PUBLIC_UPSTREAM_PORT)" == 4000 ]] || {
|
|
echo "The test public upstream port must be 4000." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value PUBLIC_HEALTH_PATH)" == /healthz/ready ]] || {
|
|
echo "The test public health path must be /healthz/ready." >&2
|
|
exit 1
|
|
}
|
|
[[ "$(require_value PUBLIC_WWW_REDIRECT)" == false ]] || {
|
|
echo "The test route must not claim the primary www hostname." >&2
|
|
exit 1
|
|
}
|
|
|
|
google_id=$(read_value GOOGLE_OAUTH_CLIENT_ID 2>/dev/null || true)
|
|
google_secret=$(read_value GOOGLE_OAUTH_CLIENT_SECRET 2>/dev/null || true)
|
|
google_authorized_party_ids=$(
|
|
read_value GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS 2>/dev/null || true
|
|
)
|
|
if [[ -n "$google_id" || -n "$google_secret" ]]; then
|
|
[[ -n "$google_id" && -n "$google_secret" ]] || {
|
|
echo "Test Google OAuth ID and secret must be configured together." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
if [[ -n "$google_authorized_party_ids" && (-z "$google_id" || -z "$google_secret") ]]; then
|
|
echo "Test Android Google authorized parties require the Google OAuth client." >&2
|
|
exit 1
|
|
fi
|
|
if [[ -n "$google_authorized_party_ids" ]] &&
|
|
! valid_nonempty_csv "$google_authorized_party_ids"; then
|
|
echo "Test GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS must be a non-empty CSV list." >&2
|
|
exit 1
|
|
fi
|
|
|
|
firebase_values=(
|
|
"$(read_value WNH_FIREBASE_APPLICATION_ID 2>/dev/null || true)"
|
|
"$(read_value WNH_FIREBASE_API_KEY 2>/dev/null || true)"
|
|
"$(read_value WNH_FIREBASE_PROJECT_ID 2>/dev/null || true)"
|
|
"$(read_value WNH_FIREBASE_GCM_SENDER_ID 2>/dev/null || true)"
|
|
)
|
|
firebase_nonempty=0
|
|
for candidate in "${firebase_values[@]}"; do
|
|
[[ -z "$candidate" ]] || firebase_nonempty=$((firebase_nonempty + 1))
|
|
done
|
|
if ((firebase_nonempty != 0 && firebase_nonempty != ${#firebase_values[@]})); then
|
|
echo "All four test WNH_FIREBASE_* Android client values must be configured together." >&2
|
|
exit 1
|
|
fi
|
|
if ((firebase_nonempty == ${#firebase_values[@]})); then
|
|
firebase_application_id=${firebase_values[0]}
|
|
firebase_project_id=${firebase_values[2]}
|
|
firebase_sender_id=${firebase_values[3]}
|
|
firebase_prefix="1:$firebase_sender_id:android:"
|
|
[[ "$firebase_sender_id" =~ ^[0-9]+$ &&
|
|
"$firebase_application_id" == "$firebase_prefix"* &&
|
|
-n "${firebase_application_id#"$firebase_prefix"}" ]] || {
|
|
echo "Test WNH_FIREBASE_APPLICATION_ID does not belong to WNH_FIREBASE_GCM_SENDER_ID." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
vapid_values=(
|
|
"$(read_value WEB_PUSH_VAPID_PUBLIC_KEY 2>/dev/null || true)"
|
|
"$(read_value WEB_PUSH_VAPID_PRIVATE_KEY 2>/dev/null || true)"
|
|
"$(read_value WEB_PUSH_VAPID_SUBJECT 2>/dev/null || true)"
|
|
)
|
|
vapid_nonempty=0
|
|
for candidate in "${vapid_values[@]}"; do
|
|
[[ -z "$candidate" ]] || vapid_nonempty=$((vapid_nonempty + 1))
|
|
done
|
|
if ((vapid_nonempty != 0 && vapid_nonempty != ${#vapid_values[@]})); then
|
|
echo "All three test WEB_PUSH_VAPID_* values must be configured together." >&2
|
|
exit 1
|
|
fi
|
|
if ((vapid_nonempty == ${#vapid_values[@]})) &&
|
|
[[ ! "${vapid_values[2]}" =~ ^(mailto:|https://) ]]; then
|
|
echo "WEB_PUSH_VAPID_SUBJECT must start with mailto: or https://." >&2
|
|
exit 1
|
|
fi
|
|
|
|
fcm_project_id=$(read_value FCM_PROJECT_ID 2>/dev/null || true)
|
|
fcm_service_account_file=$(read_value FCM_SERVICE_ACCOUNT_FILE 2>/dev/null || true)
|
|
fcm_service_account_json_base64=$(
|
|
read_value FCM_SERVICE_ACCOUNT_JSON_BASE64 2>/dev/null || true
|
|
)
|
|
if [[ -n "$fcm_service_account_file" && -n "$fcm_service_account_json_base64" ]]; then
|
|
echo "Set only one test FCM service-account credential source." >&2
|
|
exit 1
|
|
fi
|
|
if [[ -n "$fcm_project_id" || -n "$fcm_service_account_file" ||
|
|
-n "$fcm_service_account_json_base64" ]]; then
|
|
[[ -n "$fcm_project_id" &&
|
|
(-n "$fcm_service_account_file" || -n "$fcm_service_account_json_base64") ]] || {
|
|
echo "Test FCM project ID and exactly one credential source are required together." >&2
|
|
exit 1
|
|
}
|
|
|
|
fcm_credential_project_id=
|
|
if [[ -n "$fcm_service_account_file" ]]; then
|
|
command -v jq >/dev/null 2>&1 || {
|
|
echo "Required command is unavailable for FCM validation: jq" >&2
|
|
exit 1
|
|
}
|
|
[[ "$fcm_service_account_file" == /* && -r "$fcm_service_account_file" ]] || {
|
|
echo "Test FCM_SERVICE_ACCOUNT_FILE must be an absolute readable file." >&2
|
|
exit 1
|
|
}
|
|
valid_fcm_service_account_json <"$fcm_service_account_file" || {
|
|
echo "Test FCM_SERVICE_ACCOUNT_FILE is not a complete service-account JSON document." >&2
|
|
exit 1
|
|
}
|
|
fcm_credential_project_id=$(jq --raw-output '.project_id' "$fcm_service_account_file")
|
|
else
|
|
for command in base64 jq; do
|
|
command -v "$command" >/dev/null 2>&1 || {
|
|
echo "Required command is unavailable for FCM validation: $command" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
fcm_decoded_json=$(
|
|
printf '%s' "$fcm_service_account_json_base64" |
|
|
base64 --decode 2>/dev/null
|
|
) || {
|
|
echo "Test FCM_SERVICE_ACCOUNT_JSON_BASE64 is not valid Base64." >&2
|
|
exit 1
|
|
}
|
|
printf '%s' "$fcm_decoded_json" |
|
|
valid_fcm_service_account_json || {
|
|
echo "Test FCM_SERVICE_ACCOUNT_JSON_BASE64 is not a complete service-account JSON document." >&2
|
|
exit 1
|
|
}
|
|
fcm_credential_project_id=$(printf '%s' "$fcm_decoded_json" | jq --raw-output '.project_id')
|
|
fi
|
|
|
|
[[ "$fcm_credential_project_id" == "$fcm_project_id" ]] || {
|
|
echo "Test FCM service-account project does not match FCM_PROJECT_ID." >&2
|
|
exit 1
|
|
}
|
|
if ((firebase_nonempty == ${#firebase_values[@]})); then
|
|
[[ "$fcm_project_id" == "$firebase_project_id" ]] || {
|
|
echo "Test FCM_PROJECT_ID does not match WNH_FIREBASE_PROJECT_ID." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
fi
|
|
|
|
android_package=$(read_value ANDROID_APP_LINKS_PACKAGE_NAME 2>/dev/null || true)
|
|
android_fingerprints=$(read_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS 2>/dev/null || true)
|
|
if [[ -n "$android_package" || -n "$android_fingerprints" ]]; then
|
|
[[ -n "$android_package" && -n "$android_fingerprints" ]] || {
|
|
echo "Test Android App Links package and fingerprints must be configured together." >&2
|
|
exit 1
|
|
}
|
|
[[ "$android_package" =~ ^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$ ]] || {
|
|
echo "ANDROID_APP_LINKS_PACKAGE_NAME is not a valid Android application ID." >&2
|
|
exit 1
|
|
}
|
|
[[ "$android_package" == org.whoneedhelp.mobile.staging ]] || {
|
|
echo "Test Android App Links must use org.whoneedhelp.mobile.staging." >&2
|
|
exit 1
|
|
}
|
|
|
|
IFS=',' read -r -a android_fingerprint_values <<<"$android_fingerprints"
|
|
for fingerprint in "${android_fingerprint_values[@]}"; do
|
|
compact_fingerprint=${fingerprint//:/}
|
|
compact_fingerprint=${compact_fingerprint//[[:space:]]/}
|
|
[[ "$compact_fingerprint" =~ ^[0-9A-Fa-f]{64}$ ]] || {
|
|
echo "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS contains an invalid SHA-256 fingerprint." >&2
|
|
exit 1
|
|
}
|
|
done
|
|
fi
|
|
|
|
secrets=(
|
|
"$(require_value POSTGRES_PASSWORD)"
|
|
"$(require_value SECRET_KEY_BASE)"
|
|
"$(require_value HANDOVER_SECRET)"
|
|
"$(require_value RELEASE_COOKIE)"
|
|
"$(require_value METRICS_TOKEN)"
|
|
)
|
|
for ((left = 0; left < ${#secrets[@]}; left++)); do
|
|
for ((right = left + 1; right < ${#secrets[@]}; right++)); do
|
|
[[ "${secrets[$left]}" != "${secrets[$right]}" ]] || {
|
|
echo "Test secrets must be independent." >&2
|
|
exit 1
|
|
}
|
|
done
|
|
done
|
|
|
|
"$ROOT/scripts/compose.sh" "$env_file" config --quiet
|
|
echo "Test environment isolation and Compose structure passed validation."
|