#!/usr/bin/env bash # # Phase 9 definition of done: prove the Hub surface answers correctly and that # the Apprise alerts fire and are delivered. # # It exercises the deployed Control Service, not a test double: # # 1. GET /capabilities (the Hub's routing read) # 2. POST /ask "how long have the kids been on screens today?" # 3. the same total independently, from the telemetry detail table # 4. a limit-hit alert, by locking a device through the admin API # 5. an offline-too-long alert, by publishing a device status over MQTT # 6. POST /notify (accepted notification -> Apprise) # # Steps 4-6 require `CONTROL_ALERTS_ENABLED=true` and a working Apprise # destination. The script checks the `alerts` table for `delivered` rows, so a # recorded-but-undelivered alert is a failure, with the delivery error shown. # # The device is unlocked and marked online again at the end. # # Usage: # make verify-phase9 # DEVICE_ID=1 deploy/verify-phase9.sh # # Bounded docker commands only. Nothing shared is restarted or reconfigured. set -euo pipefail cd "$(dirname "$0")/.." SERVICE_URL="${CONTROL_BASE_URL:-http://127.0.0.1:8791}" ENV_FILE="${CONTROL_ENV_FILE:-environment.d/control-service.env}" CLIENTS_FILE="${MQTT_CLIENTS_FILE:-deploy/mosquitto/clients.env}" CA_FILE="${MQTT_CA_FILE:-deploy/mosquitto/tls/ca.crt}" CONTAINER="${CONTROL_CONTAINER:-webrestrict-control}" MQTT_IMAGE="${MQTT_IMAGE:-eclipse-mosquitto:latest}" DEVICE_ID="${DEVICE_ID:-}" WAIT_SECONDS="${WAIT_SECONDS:-90}" die() { echo "verify-phase9: $*" >&2; exit 1; } envval() { grep -E "^$1=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true; } # Give the engine at least one full poll to observe a state change before the # script relies on the transition. Read from the deployed configuration. INTERVAL_RAW="$(envval CONTROL_ALERT_INTERVAL)"; INTERVAL_RAW="${INTERVAL_RAW:-30s}" INTERVAL_S="${INTERVAL_RAW%s}" [[ "$INTERVAL_S" =~ ^[0-9]+$ ]] || INTERVAL_S=30 SETTLE_SECONDS=$((INTERVAL_S + 3)) HUB_TOKEN="${CONTROL_HUB_TOKEN:-$(envval CONTROL_HUB_TOKEN)}" ADMIN_TOKEN="${CONTROL_ADMIN_API_TOKEN:-$(envval CONTROL_ADMIN_API_TOKEN)}" [[ -n "$HUB_TOKEN" ]] || die "set CONTROL_HUB_TOKEN or add it to $ENV_FILE" [[ -n "$ADMIN_TOKEN" ]] || die "set CONTROL_ADMIN_API_TOKEN or add it to $ENV_FILE" [[ -f "$CLIENTS_FILE" ]] || die "missing device credentials $CLIENTS_FILE" [[ -f "$CA_FILE" ]] || die "missing CA file $CA_FILE" hub() { curl -sS -H "Authorization: Bearer $HUB_TOKEN" -H 'Content-Type: application/json' "$@"; } admin() { curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' "$@"; } # control_alerts prints the alerts table, newest first. control_alerts() { timeout 30 docker exec "$CONTAINER" /app/control alerts --limit 50; } # latest_alert_id prints the newest alert id of that kind, or empty. latest_alert_id() { control_alerts 2>/dev/null | awk -F'\t' -v k="$1" '$2==k {print $1}' | sort -n | tail -1 } # wait_alert : waits for a newer delivered alert. wait_alert() { local kind="$1" base="$2" deadline=$((SECONDS + WAIT_SECONDS)) id line status while (( SECONDS < deadline )); do id="$(latest_alert_id "$kind")" if [[ -n "$id" && "$id" -gt "$base" ]]; then line="$(control_alerts 2>/dev/null | awk -F'\t' -v i="$id" '$1==i')" status="$(echo "$line" | cut -f3)" if [[ "$status" == "delivered" ]]; then echo "$line" return 0 fi die "alert $id ($kind) is recorded but not delivered: $line" fi sleep 2 done return 1 } publish_status() { # publish_status timeout 60 docker run --rm --network host \ -v "$PWD/$CA_FILE:/tls/ca.crt:ro" \ --entrypoint sh "$MQTT_IMAGE" -c \ "mosquitto_pub -h 127.0.0.1 -p 8883 --cafile /tls/ca.crt \ -u '$2' -P '$3' -t '$1' -r -m '$4'" >/dev/null } WORK="$(mktemp -d)" DEVICE_TOPIC="" DEVICE_TOKEN="" DEVICE_PASSWORD="" cleanup() { if [[ -n "$DEVICE_ID" ]]; then timeout 20 curl -sS -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \ -H 'Content-Type: application/json' -d '{}' \ "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/unlock" >/dev/null 2>&1 || true fi if [[ -n "$DEVICE_TOPIC" && -n "$DEVICE_PASSWORD" ]]; then local now now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" publish_status "$DEVICE_STATUS_TOPIC" "$DEVICE_TOKEN" "$DEVICE_PASSWORD" \ "{\"online\":true,\"at\":\"$now\",\"app\":\"verify-phase9-cleanup\"}" || true fi rm -rf "$WORK" } trap cleanup EXIT echo "service: $SERVICE_URL" # ---- 1. capabilities ------------------------------------------------------ echo echo "== GET /capabilities" code="$(hub -o "$WORK/caps.json" -w '%{http_code}' "$SERVICE_URL/capabilities")" [[ "$code" == "200" ]] || die "capabilities returned HTTP $code: $(cat "$WORK/caps.json")" cat "$WORK/caps.json"; echo jq -e '.project == "internet_use_control"' "$WORK/caps.json" >/dev/null \ || die "unexpected capabilities payload" # ---- 2. ask --------------------------------------------------------------- echo echo "== POST /ask" ASK_BODY='{"question":"how long have the kids been on screens today?","asker":"sam","source":"console"}' code="$(hub -o "$WORK/ask.json" -w '%{http_code}' -X POST -d "$ASK_BODY" "$SERVICE_URL/ask")" [[ "$code" == "200" ]] || die "ask returned HTTP $code: $(cat "$WORK/ask.json")" cat "$WORK/ask.json"; echo answer_total="$(jq -r '.data.total_seconds // empty' "$WORK/ask.json")" [[ -n "$answer_total" ]] || die "ask returned no answer/data" ask_day="$(jq -r '.data.day' "$WORK/ask.json")" # ---- 3. independent count ------------------------------------------------- echo echo "== independent count (telemetry detail table, day $ask_day)" detail="$(timeout 30 docker exec "$CONTAINER" /app/control usage --day "$ask_day" --detail)" echo "$detail" indep_total="$(echo "$detail" | awk -F= '/^total_screen=/{print $2}')" echo echo "ask total_seconds = $answer_total" echo "independent total = $indep_total" [[ "$answer_total" == "$indep_total" ]] \ || die "ask total ($answer_total) does not match the independent count ($indep_total)" echo "PASS: the answer matches the stored telemetry" # ---- 4. limit-hit alert --------------------------------------------------- echo echo "== limit-hit alert" devices="$(admin "$SERVICE_URL/api/v1/admin/devices")" if [[ -z "$DEVICE_ID" ]]; then DEVICE_ID="$(echo "$devices" | jq -r '.devices[0].id // empty')" fi [[ -n "$DEVICE_ID" ]] || die "no device is registered" device_json="$(admin "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID")" DEVICE_TOPIC="$(echo "$device_json" | jq -r '.device.topic')" DEVICE_STATUS_TOPIC="${DEVICE_TOPIC%/config}/status" DEVICE_TOKEN="${DEVICE_TOPIC#restrict/}"; DEVICE_TOKEN="${DEVICE_TOKEN%/config}" DEVICE_PASSWORD="$(awk -F'\t' -v t="$DEVICE_TOPIC" '$3==t {print $2}' "$CLIENTS_FILE" | head -1)" [[ -n "$DEVICE_PASSWORD" ]] || die "no broker account for $DEVICE_TOPIC in $CLIENTS_FILE" echo "device: $(echo "$device_json" | jq -r '.device.name') (id $DEVICE_ID)" # Make sure the engine has seen the device unlocked, then lock it. admin -X POST -d '{}' "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/unlock" >/dev/null sleep "$SETTLE_SECONDS" limit_base="$(latest_alert_id limit)"; limit_base="${limit_base:-0}" admin -X POST -d '{"minutes":30}' "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/lock" >/dev/null limit_line="$(wait_alert limit "$limit_base")" \ || die "no delivered limit alert within ${WAIT_SECONDS}s" echo " limit alert: $limit_line" # ---- 5. offline-too-long alert ------------------------------------------- echo echo "== offline-too-long alert" offline_base="$(latest_alert_id offline)"; offline_base="${offline_base:-0}" # An old `at` makes the device immediately past the offline threshold. old_at="$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ || date -u -v-2H +%Y-%m-%dT%H:%M:%SZ)" publish_status "$DEVICE_STATUS_TOPIC" "$DEVICE_TOKEN" "$DEVICE_PASSWORD" \ "{\"online\":false,\"at\":\"$old_at\",\"app\":\"verify-phase9\"}" offline_line="$(wait_alert offline "$offline_base")" \ || die "no delivered offline alert within ${WAIT_SECONDS}s" echo " offline alert: $offline_line" # ---- 6. notify ------------------------------------------------------------ echo echo "== POST /notify" notify_base="$(latest_alert_id notify)"; notify_base="${notify_base:-0}" code="$(hub -o "$WORK/notify.json" -w '%{http_code}' -X POST \ -d '{"from":"internet_use_control","level":"important","message":"Phase 9 verification: notify path reached Sam.","person":"sam"}' \ "$SERVICE_URL/notify")" [[ "$code" == "200" ]] || die "notify returned HTTP $code: $(cat "$WORK/notify.json")" cat "$WORK/notify.json"; echo notify_line="$(wait_alert notify "$notify_base")" \ || die "no delivered notify alert within ${WAIT_SECONDS}s" echo " notify alert: $notify_line" echo echo "PASS: Phase 9 — Hub answers, limit/offline alerts delivered, notify delivered"