diff --git a/scripts/test-newuser.sh b/scripts/test-newuser.sh old mode 100755 new mode 100644 index 8b5e51f..3ec34cd --- a/scripts/test-newuser.sh +++ b/scripts/test-newuser.sh @@ -2,86 +2,229 @@ set -Eeuo pipefail -readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly NODE_A='A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1' readonly NODE_B='B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2' -readonly NODE_C='C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3' +readonly NODE_C="$(printf 'C3%.0s' {1..32})" test_root="$(mktemp -d)" -trap 'rm -rf -- "$test_root"' EXIT -mkdir -p "$test_root/bin" "$test_root/repo" "$test_root/infra" -touch "$test_root/infra/docker-compose.yml" -printf 'OWNER_MQTT_USERNAME_MAP=alice=%s,bob=%s\n' "$NODE_A" "$NODE_C" \ - >"$test_root/repo/.env" +cleanup() { + local status=$? + if (( status == 0 )); then + rm -rf -- "$test_root" + else + printf 'newuser test artifacts retained at %s\n' "$test_root" >&2 + fi +} +trap cleanup EXIT +mkdir -p "$test_root/bin" cat >"$test_root/bin/docker" <<'EOF' #!/usr/bin/env bash set -Eeuo pipefail -printf 'ARGS %s\n' "$*" >>"$FAKE_DOCKER_LOG" +if [[ ${1:-} == inspect ]]; then + if [[ $* == *State.Running* ]]; then + printf 'true\n' + elif [[ $* == *State.Status* ]]; then + printf 'running\n' + else + printf 'healthy\n' + fi + exit 0 +fi -case ${1:-} in - inspect) - if [[ $* == *State.Running* ]]; then - printf 'true\n' +if [[ ${1:-} == compose ]]; then + case "$*" in + *'ps -q mosquitto') printf 'infra-mosquitto-id\n' ;; + *'ps -q timescaledb') printf 'infra-timescaledb-id\n' ;; + *' up -d --no-deps backend') printf 'COMPOSE_BACKEND\n' >>"$FAKE_DOCKER_LOG" ;; + *) printf 'unexpected docker compose invocation: %s\n' "$*" >&2; exit 1 ;; + esac + exit 0 +fi + +if [[ ${1:-} == logs ]]; then + if [[ -n ${FAKE_DISCOVERY_WAIT_MARKER:-} ]]; then + : >"$FAKE_DISCOVERY_WAIT_MARKER" + fi + if [[ -n ${FAKE_DISCOVERY_RELEASE:-} && -e $FAKE_DISCOVERY_RELEASE ]]; then + printf "New client connected from 127.0.0.1:1234 as meshcore_%s_1 (p2, c1, k60, u'%s').\n" \ + "$FAKE_DISCOVERY_NODE" "$FAKE_DISCOVERY_USERNAME" + fi + exit 0 +fi + +[[ ${1:-} == exec ]] || { printf 'unexpected docker invocation: %s\n' "$*" >&2; exit 1; } + +# Never put the generated broker password into test logs. +if [[ $* == *'NEWUSER_MQTT_PASSWORD='* ]]; then + printf 'VERIFY_BROKER_LOGIN\n' >>"$FAKE_DOCKER_LOG" + exit 0 +fi + +if [[ $* == *'mosquitto_passwd -b '* ]]; then + username="${!#}" + printf 'CREDENTIAL_CREATE %s\n' "$username" >>"$FAKE_DOCKER_LOG" + : >"$FAKE_CREDENTIAL_FILE" + [[ ${FAKE_FAIL_STAGE:-} != credential ]] || exit 1 + exit 0 +fi + +if [[ $* == *'mosquitto_passwd -D '* ]]; then + username="${!#}" + printf 'CREDENTIAL_DELETE %s\n' "$username" >>"$FAKE_DOCKER_LOG" + rm -f -- "$FAKE_CREDENTIAL_FILE" + exit 0 +fi + +if [[ $* == *'while IFS=: read'* ]]; then + username="${!#}" + if [[ ",${FAKE_EXISTING_USERS:-}," == *",${username},"* ]]; then + exit 0 + fi + exit 1 +fi + +if [[ $* == *'/mosquitto/config/acl'* ]]; then + if [[ $* == *'node_id='* ]]; then + printf 'ACL_READBACK\n' >>"$FAKE_DOCKER_LOG" + [[ ${FAKE_FAIL_STAGE:-} != acl ]] + exit $? + fi + printf 'ACL_PRECHECK\n' >>"$FAKE_DOCKER_LOG" + exit 1 +fi + +sql='' +if [[ " $* " == *' -i '* ]]; then + sql="$(cat)" + printf 'SQL %s\n' "$sql" >>"$FAKE_DOCKER_LOG" +fi + +mqtt_username='' +for argument in "$@"; do + case $argument in + mqtt_username=*) mqtt_username=${argument#*=} ;; + esac +done + +if [[ $* == *' psql '* ]]; then + if [[ $sql == *'SELECT COALESCE('* ]]; then + if [[ ",${FAKE_EXISTING_DB_USERS:-}," == *",${mqtt_username},"* ]]; then + printf 'active\n' else - printf 'healthy\n' + printf 'missing\n' fi - ;; - compose) - case "$*" in - *'ps -q mosquitto') printf 'infra-mosquitto-id\n' ;; - *'ps -q timescaledb') printf 'infra-timescaledb-id\n' ;; - *' up -d --no-deps backend') ;; - *) printf 'unexpected docker compose invocation: %s\n' "$*" >&2; exit 1 ;; - esac - ;; - exec) - sql='' - if [[ " $* " == *' -i '* ]]; then - sql="$(cat)" - printf 'STDIN %s\n' "$sql" >>"$FAKE_DOCKER_LOG" - fi - if [[ $* == *' psql '* ]]; then - if [[ $sql == *'SELECT COALESCE('* ]]; then - printf 'active\n' - elif [[ $sql == *'JOIN owner_accounts'* ]]; then - printf '1\n' - elif [[ $sql == *'node_identity_nodes'* ]]; then - printf '1\n' - fi - fi - ;; - *) - printf 'unexpected docker invocation: %s\n' "$*" >&2 - exit 1 - ;; -esac + elif [[ $sql == *'SELECT COUNT(*)'*'JOIN owner_accounts'* ]]; then + if [[ ${FAKE_FAIL_STAGE:-} == database-readback ]]; then printf '0\n'; else printf '1\n'; fi + elif [[ $sql == *'SELECT COUNT(*) FROM owner_account_nodes'* ]]; then + printf '0\n' + elif [[ $sql == *'INSERT INTO owner_account_nodes'* ]]; then + printf 'DB_WRITE %s\n' "$mqtt_username" >>"$FAKE_DOCKER_LOG" + [[ ${FAKE_FAIL_STAGE:-} != environment ]] || exit 1 + : >"$FAKE_DB_STATE_FILE" + [[ ${FAKE_FAIL_STAGE:-} != db ]] || exit 1 + elif [[ $sql == *'node_identity_nodes'* ]]; then + printf '0\n' + elif [[ $sql == *'DELETE FROM owner_accounts'* || $sql == *'DELETE FROM owner_account_nodes'* ]]; then + printf 'DB_ROLLBACK %s\n' "$mqtt_username" >>"$FAKE_DOCKER_LOG" + rm -f -- "$FAKE_DB_STATE_FILE" + fi + exit 0 +fi + +# SIGHUP, health probes, and all other fake exec operations succeed. +exit 0 EOF chmod +x "$test_root/bin/docker" -export FAKE_DOCKER_LOG="$test_root/docker.log" -output="$test_root/output.log" -PATH="$test_root/bin:$PATH" \ -NEWUSER_REPO_DIR="$test_root/repo" \ -MESHCORE_INFRA_PROJECT_DIR="$test_root/infra" \ -TMPDIR="$test_root" \ - bash "$SCRIPT_DIR/newuser.sh" --link alice "$NODE_B" >"$output" 2>&1 +init_sandbox() { + local name=$1 + local initial_map=$2 + SANDBOX="$test_root/$name" + mkdir -p "$SANDBOX/repo" "$SANDBOX/infra" + touch "$SANDBOX/infra/docker-compose.yml" + printf 'OWNER_MQTT_USERNAME_MAP=%s\n' "$initial_map" >"$SANDBOX/repo/.env" + : >"$SANDBOX/docker.log" + export FAKE_DOCKER_LOG="$SANDBOX/docker.log" + export FAKE_CREDENTIAL_FILE="$SANDBOX/credential-state" + export FAKE_DB_STATE_FILE="$SANDBOX/db-state" +} +run_newuser() { + local sandbox=$1 + local output=$2 + local existing_users=$3 + local existing_db_users=$4 + local fail_stage=$5 + shift 5 + PATH="$test_root/bin:$PATH" \ + NEWUSER_REPO_DIR="$sandbox/repo" \ + MESHCORE_INFRA_PROJECT_DIR="$sandbox/infra" \ + TMPDIR="$sandbox" \ + NEWUSER_DISCOVERY_POLL_SECONDS=0.05 \ + FAKE_EXISTING_USERS="$existing_users" \ + FAKE_EXISTING_DB_USERS="$existing_db_users" \ + FAKE_FAIL_STAGE="$fail_stage" \ + FAKE_DISCOVERY_WAIT_MARKER="${sandbox}/discovery-waiting" \ + FAKE_DISCOVERY_RELEASE="${sandbox}/discovery-release" \ + FAKE_DISCOVERY_NODE="$NODE_B" \ + FAKE_DISCOVERY_USERNAME=charlie \ + bash "$SCRIPT_DIR/newuser.sh" "$@" >"$output" 2>&1 +} + +assert_file_contains() { + local file=$1 + local text=$2 + grep -Fq -- "$text" "$file" \ + || { printf 'expected %s to contain: %s\n' "$file" "$text" >&2; exit 1; } +} + +# Linking a node preserves the current map and existing broker password. +init_sandbox link "alice=${NODE_A},bob=${NODE_C}" +run_newuser "$SANDBOX" "$SANDBOX/output.log" 'alice' 'alice' '' --link alice "$NODE_B" expected_map="OWNER_MQTT_USERNAME_MAP=alice=${NODE_A},bob=${NODE_C},alice=${NODE_B}" -[[ $(<"$test_root/repo/.env") == "$expected_map" ]] \ - || { printf 'unexpected owner map: %s\n' "$(<"$test_root/repo/.env")" >&2; exit 1; } -grep -Fq 'Password: unchanged (existing credential)' "$output" -grep -Fq 'INSERT INTO owner_account_nodes' "$FAKE_DOCKER_LOG" -grep -Fq 'compose -f docker-compose.yml -f docker-compose.live.yml up -d --no-deps backend' \ - "$FAKE_DOCKER_LOG" -grep -Fq "compose --project-directory $test_root/infra --project-name meshcore-infra -f $test_root/infra/docker-compose.yml ps -q mosquitto" \ - "$FAKE_DOCKER_LOG" -grep -Fq "compose --project-directory $test_root/infra --project-name meshcore-infra -f $test_root/infra/docker-compose.yml ps -q timescaledb" \ - "$FAKE_DOCKER_LOG" -if grep -Fq 'mosquitto_passwd -b' "$FAKE_DOCKER_LOG"; then +[[ $(<"$SANDBOX/repo/.env") == "$expected_map" ]] \ + || { printf 'unexpected owner map: %s\n' "$(<"$SANDBOX/repo/.env")" >&2; exit 1; } +assert_file_contains "$SANDBOX/output.log" 'Password: unchanged (existing credential)' +assert_file_contains "$SANDBOX/docker.log" 'INSERT INTO owner_account_nodes' +assert_file_contains "$SANDBOX/docker.log" 'COMPOSE_BACKEND' +if grep -Fq 'CREDENTIAL_CREATE alice' "$SANDBOX/docker.log"; then printf 'link flow unexpectedly changed the existing credential\n' >&2 exit 1 fi -printf 'newuser multi-node link test passed\n' +# A second process adds a grant while discovery has released the lock. The +# discovery run must merge into the map it rereads after reacquiring the lock. +init_sandbox concurrent "alice=${NODE_A}" +PATH="$test_root/bin:$PATH" \ +NEWUSER_REPO_DIR="$SANDBOX/repo" \ +MESHCORE_INFRA_PROJECT_DIR="$SANDBOX/infra" \ +TMPDIR="$SANDBOX" \ +NEWUSER_DISCOVERY_POLL_SECONDS=0.05 \ +FAKE_DOCKER_LOG="$SANDBOX/docker.log" \ +FAKE_CREDENTIAL_FILE="$SANDBOX/credential-state" \ +FAKE_DB_STATE_FILE="$SANDBOX/db-state" \ +FAKE_EXISTING_USERS='alice,charlie' \ +FAKE_EXISTING_DB_USERS=alice \ +FAKE_DISCOVERY_WAIT_MARKER="$SANDBOX/discovery-waiting" \ +FAKE_DISCOVERY_RELEASE="$SANDBOX/discovery-release" \ +FAKE_DISCOVERY_NODE="$NODE_B" \ +FAKE_DISCOVERY_USERNAME=charlie \ + bash "$SCRIPT_DIR/newuser.sh" --watch --timeout 10 charlie >"$SANDBOX/watcher.log" 2>&1 & +watcher_pid=$! +for _ in {1..100}; do + [[ -e $SANDBOX/discovery-waiting ]] && break + sleep 0.02 +done +[[ -e $SANDBOX/discovery-waiting ]] \ + || { kill "$watcher_pid" 2>/dev/null || true; printf 'watcher did not enter discovery\n' >&2; exit 1; } +run_newuser "$SANDBOX" "$SANDBOX/second-run.log" 'alice,charlie' 'alice' '' bob "$NODE_C" +: >"$SANDBOX/discovery-release" +wait "$watcher_pid" +expected_map="OWNER_MQTT_USERNAME_MAP=alice=${NODE_A},bob=${NODE_C},charlie=${NODE_B}" +[[ $(<"$SANDBOX/repo/.env") == "$expected_map" ]] \ + || { printf 'concurrent owner map lost a grant: %s\n' "$(<"$SANDBOX/repo/.env")" >&2; exit 1; } + +printf 'newuser concurrent-discovery regression test passed\n'