Files
minerops/scripts/test-dev-baseline.sh
T
2026-08-10 19:55:20 +00:00

322 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
project_name="${MINEROPS_SMOKE_PROJECT:-minerops-smoke-$$}"
dev_port="${MINEROPS_SMOKE_PORT:-18001}"
keep_environment="${MINEROPS_SMOKE_KEEP:-false}"
base_url="http://127.0.0.1:${dev_port}"
compose=(
docker compose
--project-name "${project_name}"
--env-file .env.dev
-f compose.dev.yaml
)
tmp_prefix="/tmp/${project_name}"
admin_cookie="${tmp_prefix}-admin.cookies"
manager_cookie="${tmp_prefix}-manager.cookies"
technician_cookie="${tmp_prefix}-technician.cookies"
readonly_cookie="${tmp_prefix}-readonly.cookies"
cleanup() {
status=$?
if (( status != 0 )); then
echo
echo "Smoke test failed. Recent service logs:"
"${compose[@]}" logs --no-color --tail 160 || true
fi
rm -f "${tmp_prefix}"-*.json "${tmp_prefix}"-*.cookies "${tmp_prefix}"-*.headers
if [[ "${keep_environment}" != "true" ]]; then
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
else
echo "Keeping Compose project ${project_name} for inspection."
fi
exit "${status}"
}
trap cleanup EXIT
status_for() {
local cookie_jar="$1"
local method="$2"
local path="$3"
local body="${4:-}"
local args=(--silent --output /dev/null --write-out '%{http_code}' -X "${method}")
if [[ -n "${cookie_jar}" ]]; then
# Read and update the jar so logout and refreshed cookies behave like a browser.
args+=(-b "${cookie_jar}" -c "${cookie_jar}")
fi
if [[ -n "${body}" ]]; then
args+=(-H 'Content-Type: application/json' --data "${body}")
fi
curl "${args[@]}" "${base_url}${path}"
}
if [[ ! -f .env.dev ]]; then
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
exit 1
fi
echo "Validating Compose configuration..."
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" config --quiet
echo "Starting disposable data services..."
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach postgres redis
echo "Waiting for PostgreSQL initialization and Redis..."
for attempt in $(seq 1 180); do
postgres_ready=false
redis_ready=false
if "${compose[@]}" exec -T postgres sh -ec \
'test "$(cat /proc/1/comm)" = "postgres"; psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atqc "SELECT 1"' \
2>/dev/null | grep -qx '1'; then
postgres_ready=true
fi
if "${compose[@]}" exec -T redis redis-cli ping 2>/dev/null | grep -qx 'PONG'; then
redis_ready=true
fi
if [[ "${postgres_ready}" == "true" && "${redis_ready}" == "true" ]]; then
break
fi
if (( attempt == 180 )); then
echo "Disposable data services did not become ready within 180 seconds." >&2
exit 1
fi
sleep 1
done
echo "Building and starting the disposable backend..."
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build --no-deps backend
backend_container="$("${compose[@]}" ps -q backend)"
for attempt in $(seq 1 180); do
backend_health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${backend_container}")"
if [[ "${backend_health}" == "healthy" ]]; then
break
fi
if [[ "${backend_health}" == "exited" || "${backend_health}" == "dead" ]]; then
echo "Disposable backend exited before becoming healthy." >&2
exit 1
fi
if (( attempt == 180 )); then
echo "Disposable backend did not become healthy within 180 seconds." >&2
exit 1
fi
sleep 1
done
echo "Starting the disposable gateway..."
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --no-deps gateway
echo "Waiting for the gateway health endpoint..."
health_file="${tmp_prefix}-health.json"
for attempt in $(seq 1 90); do
if curl --fail --silent "${base_url}/api/health" >"${health_file}"; then
break
fi
if (( attempt == 90 )); then
echo "Gateway did not become healthy within 90 seconds." >&2
exit 1
fi
sleep 1
done
health_json="$(cat "${health_file}")"
[[ "${health_json}" == *'"status":"ok"'* ]]
[[ "${health_json}" == *'"environment":"development"'* ]]
[[ "${health_json}" == *'"database":"ok"'* ]]
[[ "${health_json}" == *'"redis":"ok"'* ]]
echo "Checking migrations and required schema..."
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
[[ "${current_revision}" == *"0014_repair_parts_tracking"* ]]
missing_tables="$(
"${compose[@]}" exec -T postgres sh -c \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \
< sql/verify-development-schema.sql
)"
if [[ -n "${missing_tables}" ]]; then
echo "Missing required tables:" >&2
echo "${missing_tables}" >&2
exit 1
fi
echo "Checking the unauthenticated login gate..."
setup_status="$(curl --fail --silent "${base_url}/api/auth/setup-status")"
[[ "${setup_status}" == *'"setup_required":true'* ]]
[[ "$(status_for "" GET /login)" == "200" ]]
for path in dashboard repairs locations system-health accounts monitoring/map operations/repairs assets/miners; do
[[ "$(status_for "" GET "/${path}")" == "303" ]]
done
[[ "$(status_for "" GET /api/scanner/status)" == "401" ]]
echo "Creating the first administrator and role test accounts..."
setup_response="$(curl --fail --silent -c "${admin_cookie}" \
-H 'Content-Type: application/json' \
--data '{"username":"admin","display_name":"Test Administrator","email":"admin@example.com","password":"MinerOpsTest!123"}' \
"${base_url}/api/auth/setup-admin")"
[[ "${setup_response}" == *'"role":"Admin"'* ]]
[[ "$(status_for "${admin_cookie}" GET /dashboard)" == "200" ]]
[[ "$(status_for "${admin_cookie}" GET /accounts)" == "200" ]]
for path in monitoring/map operations/repairs assets/miners; do
[[ "$(status_for "${admin_cookie}" GET "/${path}")" == "200" ]]
done
[[ "$(status_for "" GET /assets/minerops-app.css)" == "200" ]]
[[ "$(status_for "" GET /assets/minerops-app.js)" == "200" ]]
[[ "$(status_for "" GET /assets/minerops-mine-map.js)" == "200" ]]
for account in \
'manager|Operations Manager|Manager|manager@example.com' \
'technician|Repair Technician|Technician|technician@example.com' \
'readonly|Read Only User|Read Only|readonly@example.com'; do
IFS='|' read -r username display_name role email <<<"${account}"
response="$(curl --fail --silent -b "${admin_cookie}" \
-H 'Content-Type: application/json' \
--data "{\"username\":\"${username}\",\"display_name\":\"${display_name}\",\"email\":\"${email}\",\"role\":\"${role}\",\"password\":\"MinerOpsTest!123\"}" \
"${base_url}/api/admin/users")"
[[ "${response}" == *"\"role\":\"${role}\""* ]]
done
roles_response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/admin/roles")"
for role in Admin Manager Technician 'Read Only'; do
[[ "${roles_response}" == *"\"name\":\"${role}\""* ]]
done
users_response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/admin/users")"
for username in admin manager technician readonly; do
[[ "${users_response}" == *"\"username\":\"${username}\""* ]]
done
echo "Logging in each role and checking authorization boundaries..."
for login in \
"manager|${manager_cookie}|Manager" \
"technician|${technician_cookie}|Technician" \
"readonly|${readonly_cookie}|Read Only"; do
IFS='|' read -r username cookie_jar role <<<"${login}"
login_response="$(curl --fail --silent -c "${cookie_jar}" \
-H 'Content-Type: application/json' \
--data "{\"username\":\"${username}\",\"password\":\"MinerOpsTest!123\"}" \
"${base_url}/api/auth/login")"
[[ "${login_response}" == *"\"role\":\"${role}\""* ]]
[[ "$(status_for "${cookie_jar}" GET /dashboard)" == "200" ]]
[[ "$(status_for "${cookie_jar}" GET /monitoring/map)" == "200" ]]
[[ "$(status_for "${cookie_jar}" GET /operations/repairs)" == "200" ]]
[[ "$(status_for "${cookie_jar}" GET /assets/miners)" == "200" ]]
done
[[ "$(status_for "${manager_cookie}" GET /system-health)" == "200" ]]
[[ "$(status_for "${manager_cookie}" GET /accounts)" == "403" ]]
[[ "$(status_for "${manager_cookie}" GET /api/admin/users)" == "403" ]]
[[ "$(status_for "${technician_cookie}" GET /repairs)" == "200" ]]
[[ "$(status_for "${technician_cookie}" GET /system-health)" == "403" ]]
[[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]]
[[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]]
[[ "$(status_for "" GET /api/location/map)" == "401" ]]
[[ "$(status_for "${readonly_cookie}" GET /api/location/map)" == "200" ]]
[[ "$(status_for "" GET /api/miners/00000000-0000-0000-0000-000000000999/status-snapshots)" == "401" ]]
[[ "$(status_for "${readonly_cookie}" GET /api/miners/00000000-0000-0000-0000-000000000999/status-snapshots)" == "200" ]]
echo "Checking Begin Repair permission and workflow..."
"${compose[@]}" exec -T postgres sh -ec \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
INSERT INTO miners (
id,
asset_tag,
current_mac,
control_board_sn,
status,
lifecycle_status,
first_seen_at,
last_seen_at
)
VALUES (
'00000000-0000-0000-0000-000000000981',
'SMOKE-REPAIR-001',
'02:AA:BB:CC:EE:01',
'SMOKE-CB-OLD',
'unknown',
'active',
NULL,
NULL
);
SQL
[[ "$(
status_for "${readonly_cookie}" POST /api/repairs \
'{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD"}'
)" == "403" ]]
begin_repair_response="$(curl --fail --silent -b "${technician_cookie}" \
-H 'Content-Type: application/json' \
--data '{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD","notes":"Smoke test repair."}' \
"${base_url}/api/repairs")"
[[ "${begin_repair_response}" == *'"status":"OPEN"'* ]]
[[ "${begin_repair_response}" == *'"reason":"CONTROL_BOARD"'* ]]
[[ "$(
status_for "${technician_cookie}" POST /api/repairs \
'{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD"}'
)" == "409" ]]
smoke_repair_id="$(
printf '%s' "${begin_repair_response}" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["data"]["repair_id"])'
)"
[[ -n "${smoke_repair_id}" ]]
[[ "$(
status_for "${readonly_cookie}" POST /api/miners/00000000-0000-0000-0000-000000000981/replace-control-board \
'{"new_mac_address":"02:AA:BB:CC:EE:02","new_control_board_serial":"SMOKE-CB-NEW","removed_board_disposition":"sent_for_repair","replacement_reason":"failed"}'
)" == "403" ]]
board_response="$(curl --fail --silent -b "${technician_cookie}" \
-H 'Content-Type: application/json' \
--data '{"new_mac_address":"02:AA:BB:CC:EE:02","new_control_board_serial":"SMOKE-CB-NEW","removed_board_disposition":"sent_for_repair","replacement_reason":"failed","notes":"Smoke replacement."}' \
"${base_url}/api/miners/00000000-0000-0000-0000-000000000981/replace-control-board")"
[[ "${board_response}" == *'"current_mac":"02:AA:BB:CC:EE:02"'* ]]
[[ "${board_response}" == *'"event_sequence":3'* ]]
readonly_repair_detail="$(
curl --fail --silent -b "${readonly_cookie}" \
"${base_url}/api/repairs/${smoke_repair_id}"
)"
[[ "${readonly_repair_detail}" == *'"miner_asset_id":"SMOKE-REPAIR-001"'* ]]
[[ "${readonly_repair_detail}" == *'"miner_lifecycle":"in_repair"'* ]]
[[ "${readonly_repair_detail}" == *'"latest_event_sequence":3'* ]]
readonly_repair_events="$(
curl --fail --silent -b "${readonly_cookie}" \
"${base_url}/api/repairs/${smoke_repair_id}/events"
)"
[[ "${readonly_repair_events}" == *'"sequence":1'* ]]
[[ "${readonly_repair_events}" == *'"sequence":2'* ]]
[[ "${readonly_repair_events}" == *'"sequence":3'* ]]
[[ "${readonly_repair_events}" == *'"event_type":"CONTROL_BOARD_REPLACED"'* ]]
[[ "${readonly_repair_events}" == *'"event_type":"REPAIR_STARTED"'* ]]
[[ "${readonly_repair_events}" == *'"event_type":"INITIAL_NOTE"'* ]]
[[ "$(status_for "${technician_cookie}" GET /api/repairs/queue)" == "200" ]]
[[ "$(status_for "${technician_cookie}" GET /api/repairs/parts-summary)" == "200" ]]
room_status="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')"
[[ "${room_status}" == "200" || "${room_status}" == "201" ]]
me_response="$(curl --fail --silent -b "${readonly_cookie}" "${base_url}/api/auth/me")"
[[ "${me_response}" == *'"role":"Read Only"'* ]]
[[ "$(status_for "${readonly_cookie}" POST /api/auth/logout)" == "200" ]]
[[ "$(status_for "${readonly_cookie}" GET /dashboard)" == "303" ]]
echo "Checking development safety interlocks..."
"${compose[@]}" exec -T backend sh -ec '
test "$SCHEDULED_SCAN_ENABLED" = "false"
test "$MINEROPS_HUB_MODE" = "true"
test "$MINEROPS_DISABLE_LOCAL_SCANNER" = "true"
test "$MINEROPS_ENABLE_REAL_REBOOT" = "false"
test "$MINEROPS_ENABLE_REAL_POWER_CONTROL" = "false"
'
echo "Running retention rollups against the disposable baseline..."
"${compose[@]}" exec -T postgres sh -c \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< sql/minerops-retention-hourly.sql >/dev/null
"${compose[@]}" exec -T postgres sh -c \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< sql/minerops-retention-daily.sql >/dev/null
echo "Development identity baseline passed."