#!/usr/bin/env bash # The policy dashboard against real servers: two didbot-pds processes, the # built site on a third port, and both halves of the scenario run against them. # # What runs here that nothing else runs: # # 1. crates/didbot-swarm/tests/policy_rollout.rs -- an operator signs in over # OAuth, deploys a policy and a binding with one applyWrites, nudges the # server they operate, and watches a write that policy refuses be refused # by the policy's own reason. Needs both servers; skips without them. # 2. policy-site/tests/e2e-policy.mjs -- the built page in a real Chrome, # comparing the digest its own wasm computes over those two records with # the digest the governed server published. Needs Chrome; skips without it. # # Opt-in, not a commit hook: it compiles, starts servers, and wants a browser. # scripts/ci.sh runs it when DIDBOT_E2E=1. # # Usage: scripts/test-policy-e2e.sh set -euo pipefail cd "$(dirname "$0")/.." if [ "$#" -ne 0 ]; then echo "usage: $0" >&2 exit 2 fi # shellcheck source=scripts/dev-pidfile.sh . scripts/dev-pidfile.sh say() { printf '\n\033[1m=== %s ===\033[0m\n' "$1" >&2; } WORK="$(mktemp -d "${TMPDIR:-/tmp}/didbot-policy-e2e.XXXXXX")" # One stable place for the page's screenshots, overwritten each run, so the # evidence outlives the working directory below. SHOTS="${DIDBOT_E2E_SHOTS:-${TMPDIR:-/tmp}/didbot-policy-e2e-shots}" PIDS=() # Every child is killed on the way out, however this ends: a failed assertion # leaves two servers and a Vite process behind otherwise, and the next run # picks different ports and leaves two more. Each is started under `setsid`, so # signalling the negative pid reaches the whole group -- `npm run preview` is a # wrapper around the process that actually holds the port. cleanup() { local status=$? for pid in ${PIDS[@]+"${PIDS[@]}"}; do kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true done for pid in ${PIDS[@]+"${PIDS[@]}"}; do wait "$pid" 2>/dev/null || true done forget_pid policy-e2e-operator "${OPERATOR_PORT:-0}" forget_pid policy-e2e-governed "${GOVERNED_PORT:-0}" if [ "$status" -ne 0 ]; then echo "test-policy-e2e: failed; logs are in $WORK" >&2 else rm -rf "$WORK" fi exit "$status" } trap cleanup EXIT INT TERM # A port nobody chose: the kernel hands one out and we take the number. Two # runs at once, or a run alongside a dev stack on 3000, never collide. free_port() { python3 - <<'PY' import socket s = socket.socket() s.bind(("127.0.0.1", 0)) print(s.getsockname()[1]) s.close() PY } # Waits for a server to answer /health, naming it if it never does. await_health() { local name="$1" base="$2" log="$3" waited=0 while ! curl -sf "$base/health" >/dev/null 2>&1; do waited=$((waited + 1)) if [ "$waited" -gt 200 ]; then echo "test-policy-e2e: $name never answered at $base" >&2 tail -30 "$log" >&2 return 1 fi sleep 0.25 done } export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$PWD/target}" say "build: didbot-pds and the test that drives it" cargo build --quiet -p didbot-serve --bin didbot-pds cargo build --quiet --tests -p didbot-swarm PDS="$CARGO_TARGET_DIR/debug/didbot-pds" say "build: policy-site/dist, the page and the wasm it checks with" scripts/build-policy-site.sh OPERATOR_PORT="$(free_port)" GOVERNED_PORT="$(free_port)" SITE_PORT="$(free_port)" # Separate zones so the two servers are two deployments and not one seen # twice. Both are under .localhost, which resolves without a resolver and is # where didbot-pds mints with no attestation claim. OPERATOR_URL="http://opzone.localhost:$OPERATOR_PORT" GOVERNED_URL="http://govzone.localhost:$GOVERNED_PORT" SITE_URL="http://127.0.0.1:$SITE_PORT" say "the operator's server on $OPERATOR_PORT" claim_port policy-e2e-operator "$OPERATOR_PORT" didbot-pds # DIDBOT_PDS_DATA= asks for a server that holds nothing past this run; see # scripts/dev-pds.sh, which documents the empty value. DIDBOT_PDS_DATA= NO_COLOR=1 setsid "$PDS" \ --port "$OPERATOR_PORT" --zone opzone.localhost --estop-socket none \ >"$WORK/operator.log" 2>&1 & PIDS+=($!) record_pid policy-e2e-operator "$OPERATOR_PORT" "$!" await_health "the operator's server" "$OPERATOR_URL" "$WORK/operator.log" # The operator's own account, minted before the governed server starts because # that server is started knowing whose repository to read. curl -sf -X POST "$OPERATOR_URL/xrpc/bot.did.provisionAgent" \ -H 'content-type: application/json' -d '{"agentId":"operator"}' \ >"$WORK/operator-account.json" read -r OPERATOR_DID OPERATOR_TOKEN <&2 say "the governed server on $GOVERNED_PORT, operated by that account" claim_port policy-e2e-governed "$GOVERNED_PORT" didbot-pds DIDBOT_PDS_DATA= NO_COLOR=1 setsid "$PDS" \ --port "$GOVERNED_PORT" --zone govzone.localhost --estop-socket none \ --owner "$OPERATOR_DID" \ >"$WORK/governed.log" 2>&1 & PIDS+=($!) record_pid policy-e2e-governed "$GOVERNED_PORT" "$!" await_health "the governed server" "$GOVERNED_URL" "$WORK/governed.log" say "the site on $SITE_PORT" # `vite preview` and not a plain file server: it serves dist/ with the deployed # CSP and runs infra/policy-site/viewer-request.js over every path, so a route # like /rollouts resolves the way CloudFront resolves it. 127.0.0.1 and not # localhost, because the atproto loopback client redirects to the former. setsid npm --prefix policy-site run preview -- --port "$SITE_PORT" --strictPort \ >"$WORK/site.log" 2>&1 & PIDS+=($!) waited=0 while ! curl -sf "$SITE_URL/" >/dev/null 2>&1; do waited=$((waited + 1)) if [ "$waited" -gt 200 ]; then echo "test-policy-e2e: the site never answered at $SITE_URL" >&2 tail -30 "$WORK/site.log" >&2 exit 1 fi sleep 0.25 done say "the scenario: deploy, roll out, enforce" DIDBOT_E2E_OPERATOR_URL="$OPERATOR_URL" \ DIDBOT_E2E_GOVERNED_URL="$GOVERNED_URL" \ DIDBOT_E2E_OPERATOR_DID="$OPERATOR_DID" \ DIDBOT_E2E_OPERATOR_TOKEN="$OPERATOR_TOKEN" \ DIDBOT_E2E_OUT="$WORK/deployed.json" \ cargo test --quiet -p didbot-swarm --test policy_rollout -- --nocapture say "the page: the digest it computes, in a real browser" DIDBOT_E2E_SITE_URL="$SITE_URL" \ DIDBOT_E2E_DEPLOYED="$WORK/deployed.json" \ DIDBOT_E2E_SHOTS="$SHOTS" \ node --test policy-site/tests/e2e-policy.mjs say "done" echo "screenshots: $SHOTS" >&2