#!/usr/bin/env bash # # verify-restore.sh # ----------------- # Smoke-test a backup file by booting the API server against a copy of it # and asserting that admin login succeeds. # # Workflow: # 1. Pick a free high port. # 2. Copy the backup file to a temp directory. # 3. Set DB_PATH to the copy, PORT to the free port, and spawn the # server with `node src/index.js` in the background. # 4. Poll /api/auth/login with admin creds until 200 OR timeout. # 5. Send SIGTERM to the server, wait for it to exit, clean up the # temp directory. # 6. Exit 0 on success, 1 on failure. # # Usage: # ./verify-restore.sh /backups/school-20260716-120000.db # ./verify-restore.sh # auto-pick the most recent # # This script is meant to be run on the production Ubuntu 22.04 host. # It is NOT a Windows script. On a Windows dev box, only # `bash -n verify-restore.sh` (syntax check) is meaningful. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" SERVER_DIR="${PROJECT_ROOT}/server" LOG_DIR="${LOG_DIR:-${PROJECT_ROOT}/logs}" LOG_FILE="${LOG_DIR}/backup.log" VERIFY_LOG="${LOG_DIR}/verify-restore.log" # Test parameters (override on the env if you must): ADMIN_EMAIL="${ADMIN_EMAIL:-admin@school.com}" ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}" STARTUP_TIMEOUT="${STARTUP_TIMEOUT:-15}" # seconds to wait for server LOGIN_TIMEOUT="${LOGIN_TIMEOUT:-10}" # seconds for the login probe # --- Pre-flight -------------------------------------------------------------- if ! command -v curl >/dev/null 2>&1; then echo "ERROR: curl not found in PATH" >&2 exit 1 fi if ! command -v node >/dev/null 2>&1; then echo "ERROR: node not found in PATH" >&2 exit 1 fi if [ ! -d "${SERVER_DIR}" ]; then echo "ERROR: server dir not found: ${SERVER_DIR}" >&2 exit 1 fi # Pick the backup file: positional arg, or newest in /backups. if [ $# -ge 1 ]; then BACKUP_FILE="$1" else if [ -d "/backups" ]; then # shellcheck disable=SC2012 BACKUP_FILE="$(ls -1t /backups/school-*.db 2>/dev/null | head -n 1 || true)" fi if [ -z "${BACKUP_FILE:-}" ]; then echo "ERROR: no backup file given and /backups/school-*.db not found" >&2 echo "usage: $0 " >&2 exit 64 fi fi if [ ! -f "${BACKUP_FILE}" ]; then echo "ERROR: backup file not found: ${BACKUP_FILE}" >&2 exit 1 fi mkdir -p "${LOG_DIR}" touch "${VERIFY_LOG}" log() { local msg="[$(date -u +%Y-%m-%dT%H:%M:%SZ)] [verify-restore] $*" echo "${msg}" | tee -a "${VERIFY_LOG}" >&2 } # --- Pick a free port -------------------------------------------------------- # Use Node to ask the kernel for a free ephemeral port. We prefer Node over # python because Node is guaranteed to be present (the project requires it # to run, and we just used it above) and there is no Python-on-Windows # aliasing ambiguity to worry about. Falls back to a constant high port # if neither python nor node is available. pick_free_port() { if command -v node >/dev/null 2>&1; then node -e 'const s=require("net").createServer(); s.listen(0,()=>{process.stdout.write(String(s.address().port));s.close();});' return 0 fi if command -v python3 >/dev/null 2>&1; then python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()' return 0 fi if command -v python >/dev/null 2>&1; then # On Windows the Microsoft Store "python" alias can hijack the binary. # Probe it: if 'python --version' fails, fall through to the constant. if python --version >/dev/null 2>&1; then python -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()' return 0 fi fi echo "30101" } TEST_PORT="$(pick_free_port)" log "using test port ${TEST_PORT}" # --- Stage a temp working dir for the test DB -------------------------------- WORK_DIR="$(mktemp -d -t africa-alert-verify.XXXXXX)" trap 'cleanup' EXIT INT TERM TEST_DB="${WORK_DIR}/school.db" cleanup() { local rc=$? if [ -n "${SERVER_PID:-}" ] && kill -0 "${SERVER_PID}" 2>/dev/null; then log "stopping test server pid=${SERVER_PID}" kill "${SERVER_PID}" 2>/dev/null || true # Give it 5s to flush and exit; then SIGKILL. for _ in 1 2 3 4 5; do kill -0 "${SERVER_PID}" 2>/dev/null || break sleep 1 done if kill -0 "${SERVER_PID}" 2>/dev/null; then kill -9 "${SERVER_PID}" 2>/dev/null || true fi fi if [ -d "${WORK_DIR}" ]; then # Best-effort cleanup. On Windows the SQLite -wal/-shm sidecars and # even the .db file itself can be held open briefly by the OS after # the Node child exits, so rm returns EBUSY. That's fine — the temp # dir is in the OS-managed scratch space and will be reclaimed. rm -rf "${WORK_DIR}" 2>/dev/null || log "WARN: temp dir cleanup failed (rc=$?); ignoring" fi if [ "${rc}" -eq 0 ]; then log "verify-restore PASSED" else log "verify-restore FAILED (rc=${rc})" fi } cp -f "${BACKUP_FILE}" "${TEST_DB}" # If the backup has sidecars, copy them too so the test reflects a faithful # restore. for ext in wal shm; do if [ -f "${BACKUP_FILE}-${ext}" ]; then cp -f "${BACKUP_FILE}-${ext}" "${TEST_DB}-${ext}" fi done log "staged test db at ${TEST_DB}" # --- Spawn the server ------------------------------------------------------- SERVER_STDERR="${WORK_DIR}/server.stderr.log" SERVER_STDOUT="${WORK_DIR}/server.stdout.log" # Run in background; capture PIDs. We use bash to redirect so the file # handles are owned by the subshell, not this script. ( cd "${SERVER_DIR}" DB_PATH="${TEST_DB}" \ PORT="${TEST_PORT}" \ NODE_ENV=development \ node src/index.js \ >"${SERVER_STDOUT}" 2>"${SERVER_STDERR}" ) & SERVER_PID=$! log "spawned test server pid=${SERVER_PID}" # --- Wait for readiness ------------------------------------------------------ # Probe the login endpoint with bad creds; the server is ready when we # get a 401 (route mounted, DB queryable) instead of a connection error. ready=0 deadline=$(( $(date +%s) + STARTUP_TIMEOUT )) while [ "$(date +%s)" -lt "${deadline}" ]; do if ! kill -0 "${SERVER_PID}" 2>/dev/null; then log "ERROR: server process exited before becoming ready" log "--- server stdout ---" sed 's/^/ /' "${SERVER_STDOUT}" | tee -a "${VERIFY_LOG}" >&2 || true log "--- server stderr ---" sed 's/^/ /' "${SERVER_STDERR}" | tee -a "${VERIFY_LOG}" >&2 || true exit 1 fi CODE="$(curl -s -o /dev/null -w '%{http_code}' \ -X POST \ -H 'Content-Type: application/json' \ -d '{"email":"__probe__","password":"__probe__"}' \ --max-time 2 \ "http://127.0.0.1:${TEST_PORT}/api/auth/login" || echo 000)" case "${CODE}" in 400|401) ready=1; break ;; # 400=missing creds, 401=invalid creds — both mean route+DB are alive 000) sleep 0.3 ;; # connection refused / timeout *) sleep 0.3 ;; # anything else (500?) — keep trying esac done if [ "${ready}" -ne 1 ]; then log "ERROR: server did not become ready within ${STARTUP_TIMEOUT}s" log "--- server stdout ---" sed 's/^/ /' "${SERVER_STDOUT}" | tee -a "${VERIFY_LOG}" >&2 || true log "--- server stderr ---" sed 's/^/ /' "${SERVER_STDERR}" | tee -a "${VERIFY_LOG}" >&2 || true exit 1 fi log "server ready on :${TEST_PORT}" # --- Real login probe ------------------------------------------------------- log "probing /api/auth/login as ${ADMIN_EMAIL}" LOGIN_RESPONSE="$(mktemp)" LOGIN_DEADLINE=$(( $(date +%s) + LOGIN_TIMEOUT )) LOGIN_CODE=000 while [ "$(date +%s)" -lt "${LOGIN_DEADLINE}" ]; do LOGIN_CODE="$(curl -s -o "${LOGIN_RESPONSE}" -w '%{http_code}' \ -X POST \ -H 'Content-Type: application/json' \ -d "{\"email\":\"${ADMIN_EMAIL}\",\"password\":\"${ADMIN_PASSWORD}\"}" \ --max-time 5 \ "http://127.0.0.1:${TEST_PORT}/api/auth/login" || echo 000)" if [ "${LOGIN_CODE}" = "200" ]; then break fi sleep 0.5 done log "login response code: ${LOGIN_CODE}" # Print only the first 200 bytes of the response body — we don't want to # leak the JWT into the log file. head -c 200 "${LOGIN_RESPONSE}" | sed 's/^/ /' | tee -a "${VERIFY_LOG}" >&2 || true echo "" | tee -a "${VERIFY_LOG}" >&2 if [ "${LOGIN_CODE}" != "200" ]; then log "ERROR: admin login did not return 200 (got ${LOGIN_CODE})" rm -f "${LOGIN_RESPONSE}" exit 1 fi # Sanity check: response should look like JSON with a `token` field. if ! head -c 200 "${LOGIN_RESPONSE}" | grep -q '"token"'; then log "ERROR: response body did not contain a 'token' field" rm -f "${LOGIN_RESPONSE}" exit 1 fi rm -f "${LOGIN_RESPONSE}" log "login OK — backup is restorable" # cleanup() will run on EXIT and stop the server + remove WORK_DIR. exit 0