/ scripts / radicle-e2e.sh
radicle-e2e.sh
  1  #!/usr/bin/env bash
  2  #
  3  # Radicle Multi-Device E2E Demo
  4  #
  5  # Automated (non-interactive) test that exercises the real rad + auths CLIs
  6  # together: sets up two Radicle nodes, creates an Auths identity, links both
  7  # nodes as devices, creates a project, verifies authorizations, and revokes
  8  # a device.
  9  #
 10  # Prerequisites: rad CLI installed (https://radicle.xyz)
 11  # Usage:        just e2e-radicle   OR   bash scripts/radicle-e2e.sh
 12  #
 13  set -euo pipefail
 14  
 15  # ── Colors ────────────────────────────────────────────────────────────────────
 16  RED='\033[0;31m'
 17  GREEN='\033[0;32m'
 18  YELLOW='\033[1;33m'
 19  BLUE='\033[0;34m'
 20  CYAN='\033[0;36m'
 21  BOLD='\033[1m'
 22  DIM='\033[2m'
 23  NC='\033[0m'
 24  
 25  # ── Paths ─────────────────────────────────────────────────────────────────────
 26  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 27  REPO_ROOT="$(dirname "$SCRIPT_DIR")"
 28  AUTHS_BIN="$REPO_ROOT/target/release/auths"
 29  AUTHS_SIGN_BIN="$REPO_ROOT/target/release/auths-sign"
 30  DEMO_DIR="$(mktemp -d)"
 31  
 32  # Existing Radicle project to push patches to
 33  PROJECT_RID="rad:zTfsUzHQFrAUMsMgTqT5hFwfGon1"
 34  
 35  # rosa.radicle.xyz is PERMISSIVE (seeds all public repos).
 36  # seed.radicle.xyz is SELECTIVE (Radicle team repos only) — will not host our project.
 37  ROSA_NID="z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo"
 38  
 39  # Auths storage
 40  AUTHS_HOME="$DEMO_DIR/.auths"
 41  
 42  # Two simulated Radicle nodes
 43  RAD_NODE1_HOME="$DEMO_DIR/rad-node-1"
 44  RAD_NODE2_HOME="$DEMO_DIR/rad-node-2"
 45  
 46  # ── Headless environment ──────────────────────────────────────────────────────
 47  export AUTHS_KEYCHAIN_BACKEND=file
 48  export AUTHS_KEYCHAIN_FILE="$DEMO_DIR/keys.enc"
 49  export AUTHS_PASSPHRASE=test-e2e-passphrase
 50  export RAD_PASSPHRASE="e2e-rad"
 51  export GIT_AUTHOR_NAME="E2E Tester"
 52  export GIT_AUTHOR_EMAIL="e2e@test.local"
 53  export GIT_COMMITTER_NAME="E2E Tester"
 54  export GIT_COMMITTER_EMAIL="e2e@test.local"
 55  
 56  # ── Phase tracking ────────────────────────────────────────────────────────────
 57  PHASE_RESULTS=()
 58  CURRENT_PHASE=""
 59  
 60  phase_start() {
 61      CURRENT_PHASE="$1"
 62      echo ""
 63      echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
 64      echo -e "${BOLD}  $1${NC}"
 65      echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
 66      echo ""
 67  }
 68  
 69  phase_pass() {
 70      echo ""
 71      echo -e "  ${GREEN}✓ PASS${NC}: $CURRENT_PHASE"
 72      PHASE_RESULTS+=("PASS: $CURRENT_PHASE")
 73  }
 74  
 75  phase_fail() {
 76      local msg="${1:-assertion failed}"
 77      echo ""
 78      echo -e "  ${RED}✗ FAIL${NC}: $CURRENT_PHASE — $msg"
 79      PHASE_RESULTS+=("FAIL: $CURRENT_PHASE — $msg")
 80  }
 81  
 82  assert_ok() {
 83      local desc="$1"; shift
 84      if "$@" >/dev/null 2>&1; then
 85          echo -e "  ${GREEN}✓${NC} $desc"
 86      else
 87          echo -e "  ${RED}✗${NC} $desc"
 88          phase_fail "$desc"
 89          exit 1
 90      fi
 91  }
 92  
 93  assert_contains() {
 94      local desc="$1"
 95      local haystack="$2"
 96      local needle="$3"
 97      if echo "$haystack" | grep -q "$needle"; then
 98          echo -e "  ${GREEN}✓${NC} $desc"
 99      else
100          echo -e "  ${RED}✗${NC} $desc (expected to find: $needle)"
101          phase_fail "$desc"
102          exit 1
103      fi
104  }
105  
106  assert_not_contains() {
107      local desc="$1"
108      local haystack="$2"
109      local needle="$3"
110      if echo "$haystack" | grep -q "$needle"; then
111          echo -e "  ${RED}✗${NC} $desc (unexpectedly found: $needle)"
112          phase_fail "$desc"
113          exit 1
114      else
115          echo -e "  ${GREEN}✓${NC} $desc"
116      fi
117  }
118  
119  info() {
120      echo -e "  ${CYAN}→${NC} $1"
121  }
122  
123  # ── Cleanup ───────────────────────────────────────────────────────────────────
124  cleanup() {
125      echo ""
126      echo -e "${DIM}Stopping any running Radicle nodes...${NC}"
127      RAD_HOME="$RAD_NODE1_HOME" rad node stop 2>/dev/null || true
128      RAD_HOME="$RAD_NODE2_HOME" rad node stop 2>/dev/null || true
129      echo -e "${DIM}Cleaning up $DEMO_DIR ...${NC}"
130      rm -rf "$DEMO_DIR"
131  }
132  trap cleanup EXIT
133  
134  # ══════════════════════════════════════════════════════════════════════════════
135  #  Phase 0 — Prerequisites & Build
136  # ══════════════════════════════════════════════════════════════════════════════
137  phase_start "Phase 0: Prerequisites & Build"
138  
139  if ! command -v rad >/dev/null 2>&1; then
140      echo -e "  ${RED}✗${NC} 'rad' CLI not found."
141      echo -e "    Install Radicle: ${CYAN}https://radicle.xyz${NC}"
142      exit 1
143  fi
144  echo -e "  ${GREEN}✓${NC} rad CLI found: $(command -v rad)"
145  
146  if [[ ! -x "$AUTHS_BIN" ]]; then
147      info "Building auths (release mode)..."
148      (cd "$REPO_ROOT" && cargo build --release --package auths_cli 2>&1) \
149          | grep -E "Compiling|Finished" \
150          | sed 's/^/    /' || true
151  fi
152  assert_ok "auths binary is executable" test -x "$AUTHS_BIN"
153  assert_ok "auths-sign binary is executable" test -x "$AUTHS_SIGN_BIN"
154  
155  info "Demo directory: $DEMO_DIR"
156  mkdir -p "$AUTHS_HOME" "$RAD_NODE1_HOME" "$RAD_NODE2_HOME"
157  
158  phase_pass
159  
160  # ══════════════════════════════════════════════════════════════════════════════
161  #  Phase 1 — Set up two Radicle nodes
162  # ══════════════════════════════════════════════════════════════════════════════
163  phase_start "Phase 1: Set up two Radicle nodes"
164  
165  # Pre-generate deterministic 32-byte seeds.  RAD_KEYGEN_SEED lets us control
166  # the Ed25519 seed that `rad auth` uses, so we know the raw bytes without
167  # having to parse the OpenSSH private-key file that Radicle writes to disk.
168  NODE1_SEED_HEX=$(openssl rand -hex 32)
169  NODE2_SEED_HEX=$(openssl rand -hex 32)
170  
171  # TEST-ONLY: Write seed bytes to disk so `auths key import --seed-file` can
172  # read them. In production, `rad auth` will pass the seed directly to the
173  # auths SDK without touching the filesystem. This is the only place where
174  # seed material hits disk, and the temp directory is cleaned up on exit.
175  NODE1_SEED="$DEMO_DIR/node1.seed"
176  NODE2_SEED="$DEMO_DIR/node2.seed"
177  echo -n "$NODE1_SEED_HEX" | xxd -r -p > "$NODE1_SEED"
178  echo -n "$NODE2_SEED_HEX" | xxd -r -p > "$NODE2_SEED"
179  
180  info "Initializing Radicle node 1..."
181  RAD_HOME="$RAD_NODE1_HOME" RAD_KEYGEN_SEED="$NODE1_SEED_HEX" rad auth --alias node1 2>&1 | sed 's/^/    /' || true
182  
183  info "Initializing Radicle node 2..."
184  RAD_HOME="$RAD_NODE2_HOME" RAD_KEYGEN_SEED="$NODE2_SEED_HEX" rad auth --alias node2 2>&1 | sed 's/^/    /' || true
185  
186  # Extract DIDs — rad self --did outputs did:key:z6Mk... on stdout
187  NODE1_DID=$(RAD_HOME="$RAD_NODE1_HOME" rad self --did 2>/dev/null | tr -d '[:space:]')
188  NODE2_DID=$(RAD_HOME="$RAD_NODE2_HOME" rad self --did 2>/dev/null | tr -d '[:space:]')
189  
190  assert_ok "node 1 DID is not empty" test -n "$NODE1_DID"
191  assert_ok "node 2 DID is not empty" test -n "$NODE2_DID"
192  
193  # Derive NIDs (z6Mk...) from DIDs for rad node connect
194  NODE1_NID="${NODE1_DID#did:key:}"
195  NODE2_NID="${NODE2_DID#did:key:}"
196  
197  info "Node 1 DID: $NODE1_DID"
198  info "Node 2 DID: $NODE2_DID"
199  
200  assert_ok "node 1 seed is 32 bytes" test "$(wc -c < "$NODE1_SEED" | tr -d ' ')" -eq 32
201  assert_ok "node 2 seed is 32 bytes" test "$(wc -c < "$NODE2_SEED" | tr -d ' ')" -eq 32
202  
203  phase_pass
204  
205  # ══════════════════════════════════════════════════════════════════════════════
206  #  Phase 2 — Create Auths identity
207  # ══════════════════════════════════════════════════════════════════════════════
208  phase_start "Phase 2: Create Auths identity"
209  
210  # Metadata file for the identity
211  cat > "$DEMO_DIR/metadata.json" <<'METAJSON'
212  {
213    "xyz.radicle.project": {
214      "name": "e2e-radicle-demo"
215    },
216    "profile": {
217      "name": "Radicle E2E Tester"
218    }
219  }
220  METAJSON
221  
222  info "Creating identity (RIP-X layout is the default)..."
223  CREATE_OUTPUT=$("$AUTHS_BIN" --repo "$AUTHS_HOME" id create \
224      --metadata-file "$DEMO_DIR/metadata.json" \
225      --local-key-alias identity-key \
226      2>&1) || true
227  echo "$CREATE_OUTPUT" | sed 's/^/    /'
228  
229  # Extract Controller DID from create output, fall back to id show
230  CONTROLLER_DID=$(echo "$CREATE_OUTPUT" | grep 'Controller DID:' | head -1 | awk -F': ' '{print $NF}' | tr -d '[:space:]')
231  if [ -z "$CONTROLLER_DID" ]; then
232      ID_SHOW_OUTPUT=$("$AUTHS_BIN" --repo "$AUTHS_HOME" id show 2>&1 || true)
233      CONTROLLER_DID=$(echo "$ID_SHOW_OUTPUT" | grep 'Controller DID' | head -1 | awk -F': ' '{print $NF}' | tr -d '[:space:]')
234  fi
235  
236  assert_ok "controller DID is not empty" test -n "$CONTROLLER_DID"
237  info "Controller DID: $CONTROLLER_DID"
238  
239  phase_pass
240  
241  # ══════════════════════════════════════════════════════════════════════════════
242  #  Phase 3 — Link device 1 (Radicle node 1)
243  # ══════════════════════════════════════════════════════════════════════════════
244  phase_start "Phase 3: Link device 1 (Radicle node 1)"
245  
246  info "Importing node 1 seed into auths keychain..."
247  IMPORT1_OUTPUT=$("$AUTHS_BIN" key import \
248      --alias node1-key \
249      --seed-file "$NODE1_SEED" \
250      --controller-did "$CONTROLLER_DID" \
251      2>&1) || { echo "$IMPORT1_OUTPUT" | sed 's/^/    /'; phase_fail "key import node1"; exit 1; }
252  echo "$IMPORT1_OUTPUT" | sed 's/^/    /'
253  
254  info "Linking node 1 as a device..."
255  LINK1_OUTPUT=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device link \
256      --identity-key-alias identity-key \
257      --device-key-alias node1-key \
258      --device-did "$NODE1_DID" \
259      --note "Radicle Node 1" \
260      2>&1) || { echo "$LINK1_OUTPUT" | sed 's/^/    /'; phase_fail "device link node1"; exit 1; }
261  echo "$LINK1_OUTPUT" | sed 's/^/    /'
262  
263  # Verify device 1 appears in the list
264  DEVICE_LIST=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device list 2>/dev/null || true)
265  assert_contains "device list contains node 1 DID" "$DEVICE_LIST" "$NODE1_DID"
266  
267  phase_pass
268  
269  # ══════════════════════════════════════════════════════════════════════════════
270  #  Phase 4 — Link device 2 (Radicle node 2)
271  # ══════════════════════════════════════════════════════════════════════════════
272  phase_start "Phase 4: Link device 2 (Radicle node 2)"
273  
274  info "Importing node 2 seed into auths keychain..."
275  IMPORT2_OUTPUT=$("$AUTHS_BIN" key import \
276      --alias node2-key \
277      --seed-file "$NODE2_SEED" \
278      --controller-did "$CONTROLLER_DID" \
279      2>&1) || { echo "$IMPORT2_OUTPUT" | sed 's/^/    /'; phase_fail "key import node2"; exit 1; }
280  echo "$IMPORT2_OUTPUT" | sed 's/^/    /'
281  
282  info "Linking node 2 as a device..."
283  LINK2_OUTPUT=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device link \
284      --identity-key-alias identity-key \
285      --device-key-alias node2-key \
286      --device-did "$NODE2_DID" \
287      --note "Radicle Node 2" \
288      --capabilities sign_commit \
289      2>&1) || { echo "$LINK2_OUTPUT" | sed 's/^/    /'; phase_fail "device link node2"; exit 1; }
290  echo "$LINK2_OUTPUT" | sed 's/^/    /'
291  
292  # Verify both devices appear
293  DEVICE_LIST=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device list 2>/dev/null || true)
294  assert_contains "device list contains node 1" "$DEVICE_LIST" "$NODE1_DID"
295  assert_contains "device list contains node 2" "$DEVICE_LIST" "$NODE2_DID"
296  
297  phase_pass
298  
299  # ══════════════════════════════════════════════════════════════════════════════
300  #  Phase 5 — Clone existing Radicle project (from node 1)
301  # ══════════════════════════════════════════════════════════════════════════════
302  phase_start "Phase 5: Clone existing Radicle project"
303  
304  PROJECT_DIR="$DEMO_DIR/e2e-project"
305  E2E_PORT1=19876
306  
307  info "Project RID: $PROJECT_RID"
308  info "Starting Radicle node 1 for clone (port $E2E_PORT1)..."
309  RAD_HOME="$RAD_NODE1_HOME" rad node start -- --listen 0.0.0.0:$E2E_PORT1 2>&1 | sed 's/^/    /' || true
310  sleep 2
311  
312  info "Connecting node 1 to rosa seed..."
313  RAD_HOME="$RAD_NODE1_HOME" rad node connect "${ROSA_NID}@rosa.radicle.xyz:8776" --timeout 10 2>&1 | sed 's/^/    /' || true
314  
315  info "Waiting for seed connection to establish..."
316  for i in $(seq 1 10); do
317      if RAD_HOME="$RAD_NODE1_HOME" rad node sessions 2>/dev/null | grep -q "$ROSA_NID"; then
318          echo -e "  ${GREEN}✓${NC} Connected to rosa seed"
319          break
320      fi
321      if [ "$i" -eq 10 ]; then
322          echo -e "  ${YELLOW}⚠${NC} Seed connection not confirmed after 10s — attempting clone anyway"
323      fi
324      sleep 1
325  done
326  
327  info "Cloning project from network..."
328  CLONE_OK=false
329  for attempt in 1 2 3; do
330      CLONE_OUTPUT=$(RAD_HOME="$RAD_NODE1_HOME" rad clone "$PROJECT_RID" "$PROJECT_DIR" --seed "$ROSA_NID" --timeout 30 2>&1) && {
331          CLONE_OK=true
332          echo "$CLONE_OUTPUT" | sed 's/^/    /'
333          break
334      }
335      echo "$CLONE_OUTPUT" | sed 's/^/    /'
336      if [ "$attempt" -lt 3 ]; then
337          info "Clone attempt $attempt failed, retrying in 5s..."
338          sleep 5
339      fi
340  done
341  
342  info "Stopping node 1..."
343  RAD_HOME="$RAD_NODE1_HOME" rad node stop 2>/dev/null || true
344  
345  if ! $CLONE_OK || [ ! -d "$PROJECT_DIR/.git" ]; then
346      echo -e "  ${RED}✗${NC} Clone failed after 3 attempts — is rosa.radicle.xyz reachable?"
347      phase_fail "clone failed"
348      exit 1
349  fi
350  
351  # Configure git identity for commits
352  (
353      cd "$PROJECT_DIR"
354      git config user.name "E2E Tester"
355      git config user.email "e2e@test.local"
356      git config commit.gpgsign false
357  )
358  
359  info "Radicle project RID: $PROJECT_RID"
360  echo -e "  ${GREEN}✓${NC} Radicle project cloned"
361  
362  phase_pass
363  
364  # ══════════════════════════════════════════════════════════════════════════════
365  #  Phase 6 — Verify both devices are authorized
366  # ══════════════════════════════════════════════════════════════════════════════
367  phase_start "Phase 6: Verify both devices are authorized"
368  
369  DEVICE_LIST=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device list 2>/dev/null || true)
370  
371  info "Device list output:"
372  echo "$DEVICE_LIST" | sed 's/^/    /'
373  
374  assert_contains "node 1 is active" "$DEVICE_LIST" "$NODE1_DID"
375  assert_contains "node 2 is active" "$DEVICE_LIST" "$NODE2_DID"
376  
377  # Count active devices (each DID line = 1 device)
378  DEVICE_COUNT=$(echo "$DEVICE_LIST" | grep -c "did:key:" || true)
379  assert_ok "exactly 2 devices listed" test "$DEVICE_COUNT" -eq 2
380  
381  phase_pass
382  
383  # ══════════════════════════════════════════════════════════════════════════════
384  #  Phase 6b — Verify storage layout
385  # ══════════════════════════════════════════════════════════════════════════════
386  phase_start "Phase 6b: Verify storage layout"
387  
388  # The CLI stores all state under a single packed ref: refs/auths/registry
389  # Identity, attestations, and KEL events are tree paths within that ref.
390  info "Checking packed registry ref..."
391  REGISTRY_REF_EXISTS=$(git -C "$AUTHS_HOME" show-ref refs/auths/registry 2>/dev/null || true)
392  assert_ok "refs/auths/registry exists" test -n "$REGISTRY_REF_EXISTS"
393  
394  info "Checking device attestation entries in registry tree..."
395  # Sanitized DID format: did_key_z6Mk... (non-alphanumeric replaced with underscores)
396  NODE1_SANITIZED=$(echo "$NODE1_DID" | sed 's/[^a-zA-Z0-9]/_/g')
397  NODE2_SANITIZED=$(echo "$NODE2_DID" | sed 's/[^a-zA-Z0-9]/_/g')
398  
399  # List the full registry tree to find device entries
400  REGISTRY_TREE=$(git -C "$AUTHS_HOME" ls-tree -r --name-only refs/auths/registry 2>/dev/null || true)
401  
402  assert_contains "node 1 device entry in registry" "$REGISTRY_TREE" "$NODE1_SANITIZED"
403  assert_contains "node 2 device entry in registry" "$REGISTRY_TREE" "$NODE2_SANITIZED"
404  
405  RESOLVE_OK=true
406  
407  info "Resolving device 1 DID to controller..."
408  RESOLVE_OUTPUT_1=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device resolve --device-did "$NODE1_DID" 2>&1) || {
409      echo -e "  ${RED}✗${NC} device resolve failed for $NODE1_DID"
410      echo "$RESOLVE_OUTPUT_1" | sed 's/^/    /'
411      phase_fail "device 1 resolution"
412      RESOLVE_OK=false
413  }
414  RESOLVED_DID_1=$(echo "$RESOLVE_OUTPUT_1" | tr -d '[:space:]')
415  
416  if $RESOLVE_OK; then
417      if [ "$RESOLVED_DID_1" = "$CONTROLLER_DID" ]; then
418          echo -e "  ${GREEN}✓${NC} Device 1 resolves to controller DID"
419      else
420          echo -e "  ${RED}✗${NC} Device 1 resolved to '$RESOLVED_DID_1', expected '$CONTROLLER_DID'"
421          phase_fail "device 1 resolution mismatch"
422          RESOLVE_OK=false
423      fi
424  fi
425  
426  info "Resolving device 2 DID to controller..."
427  RESOLVE_OUTPUT_2=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device resolve --device-did "$NODE2_DID" 2>&1) || {
428      echo -e "  ${RED}✗${NC} device resolve failed for $NODE2_DID"
429      echo "$RESOLVE_OUTPUT_2" | sed 's/^/    /'
430      phase_fail "device 2 resolution"
431      RESOLVE_OK=false
432  }
433  RESOLVED_DID_2=$(echo "$RESOLVE_OUTPUT_2" | tr -d '[:space:]')
434  
435  if $RESOLVE_OK; then
436      if [ "$RESOLVED_DID_2" = "$CONTROLLER_DID" ]; then
437          echo -e "  ${GREEN}✓${NC} Device 2 resolves to controller DID"
438      else
439          echo -e "  ${RED}✗${NC} Device 2 resolved to '$RESOLVED_DID_2', expected '$CONTROLLER_DID'"
440          phase_fail "device 2 resolution mismatch"
441          RESOLVE_OK=false
442      fi
443  fi
444  
445  if $RESOLVE_OK; then
446      if [ "$RESOLVED_DID_1" = "$RESOLVED_DID_2" ]; then
447          echo -e "  ${GREEN}✓${NC} Both devices resolve to the same controller identity"
448      else
449          echo -e "  ${RED}✗${NC} Devices resolved to different identities"
450          phase_fail "identity mismatch between devices"
451          RESOLVE_OK=false
452      fi
453  fi
454  
455  if $RESOLVE_OK; then
456      phase_pass
457  fi
458  
459  # ══════════════════════════════════════════════════════════════════════════════
460  #  Phase 6c — Verify KEL integrity and attestation anchoring
461  # ══════════════════════════════════════════════════════════════════════════════
462  phase_start "Phase 6c: Verify KEL integrity and attestation anchoring"
463  
464  PHASE6C_OK=true
465  
466  # Extract KERI prefix from controller DID
467  KERI_PREFIX="${CONTROLLER_DID#did:keri:}"
468  info "KERI prefix: $KERI_PREFIX"
469  
470  # Check KEL exists in the registry tree
471  KEL_ENTRIES=$(git -C "$AUTHS_HOME" ls-tree -r --name-only refs/auths/registry 2>/dev/null | grep "kel" || true)
472  if [ -n "$KEL_ENTRIES" ]; then
473      echo -e "  ${GREEN}✓${NC} KEL entries found in registry"
474  else
475      echo -e "  ${RED}✗${NC} No KEL entries found in registry"
476      PHASE6C_OK=false
477  fi
478  
479  # Verify the controller DID is a valid KERI prefix (starts with E for Blake3)
480  if [[ "$KERI_PREFIX" == E* ]]; then
481      echo -e "  ${GREEN}✓${NC} KERI prefix has valid Blake3 derivation code"
482  else
483      echo -e "  ${RED}✗${NC} KERI prefix does not start with 'E': $KERI_PREFIX"
484      PHASE6C_OK=false
485  fi
486  
487  # Verify both device attestations exist in registry tree
488  if [ -n "$REGISTRY_TREE" ]; then
489      ATT_COUNT=$(echo "$REGISTRY_TREE" | grep -c "attestation\|signature" || true)
490      if [ "$ATT_COUNT" -ge 2 ]; then
491          echo -e "  ${GREEN}✓${NC} At least 2 attestation/signature entries found ($ATT_COUNT total)"
492      else
493          echo -e "  ${RED}✗${NC} Expected at least 2 attestation entries, found $ATT_COUNT"
494          PHASE6C_OK=false
495      fi
496  fi
497  
498  # Cross-validate: resolved controller DID matches the KERI prefix in registry
499  if $RESOLVE_OK; then
500      if [[ "$RESOLVED_DID_1" == *"$KERI_PREFIX"* ]]; then
501          echo -e "  ${GREEN}✓${NC} Resolved DID contains KERI prefix from registry"
502      else
503          echo -e "  ${RED}✗${NC} Resolved DID '$RESOLVED_DID_1' does not contain KERI prefix '$KERI_PREFIX'"
504          PHASE6C_OK=false
505      fi
506  fi
507  
508  if $PHASE6C_OK; then
509      phase_pass
510  else
511      phase_fail "KEL integrity verification"
512  fi
513  
514  # ══════════════════════════════════════════════════════════════════════════════
515  #  Phase 7 — Signed commits + Radicle patches from both devices
516  # ══════════════════════════════════════════════════════════════════════════════
517  phase_start "Phase 7: Signed commits + Radicle patches from both devices"
518  
519  PHASE7_OK=true
520  
521  # ── Export public keys for allowed_signers ─────────────────────────────
522  info "Exporting public keys for both devices..."
523  PUB1=$("$AUTHS_BIN" key export --alias node1-key --passphrase "$AUTHS_PASSPHRASE" --format pub 2>&1) || {
524      echo -e "  ${RED}✗${NC} Failed to export node1-key public key"
525      echo "$PUB1" | sed 's/^/    /'
526      phase_fail "node1 key export"
527      PHASE7_OK=false
528  }
529  PUB2=$("$AUTHS_BIN" key export --alias node2-key --passphrase "$AUTHS_PASSPHRASE" --format pub 2>&1) || {
530      echo -e "  ${RED}✗${NC} Failed to export node2-key public key"
531      echo "$PUB2" | sed 's/^/    /'
532      phase_fail "node2 key export"
533      PHASE7_OK=false
534  }
535  
536  if $PHASE7_OK; then
537      info "Node 1 pubkey: $PUB1"
538      info "Node 2 pubkey: $PUB2"
539  
540      # ── Create allowed_signers file ────────────────────────────────────
541      ALLOWED_SIGNERS="$DEMO_DIR/allowed_signers"
542      echo "e2e@test.local $PUB1" > "$ALLOWED_SIGNERS"
543      echo "e2e@test.local $PUB2" >> "$ALLOWED_SIGNERS"
544      echo -e "  ${GREEN}✓${NC} Created allowed_signers file"
545  
546      # ── Configure git for SSH signing in project dir ───────────────────
547      (
548          cd "$PROJECT_DIR"
549          git config gpg.format ssh
550          git config gpg.ssh.program "$AUTHS_SIGN_BIN"
551          git config gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS"
552          git config commit.gpgsign true
553      )
554      echo -e "  ${GREEN}✓${NC} Configured git for auths-sign SSH signing"
555  
556      # ── Start Radicle nodes ────────────────────────────────────────────
557      E2E_PORT1=19876
558      E2E_PORT2=19877
559  
560      info "Starting Radicle node 1 (port $E2E_PORT1)..."
561      RAD_HOME="$RAD_NODE1_HOME" rad node start -- --listen 0.0.0.0:$E2E_PORT1 2>&1 | sed 's/^/    /' || true
562      sleep 2
563  
564      info "Starting Radicle node 2 (port $E2E_PORT2)..."
565      RAD_HOME="$RAD_NODE2_HOME" rad node start -- --listen 0.0.0.0:$E2E_PORT2 2>&1 | sed 's/^/    /' || true
566      sleep 2
567  
568      info "Connecting nodes to rosa seed..."
569      RAD_HOME="$RAD_NODE1_HOME" rad node connect "${ROSA_NID}@rosa.radicle.xyz:8776" --timeout 10 2>&1 | sed 's/^/    /' || true
570      RAD_HOME="$RAD_NODE2_HOME" rad node connect "${ROSA_NID}@rosa.radicle.xyz:8776" --timeout 10 2>&1 | sed 's/^/    /' || true
571  
572      info "Connecting node 2 to node 1..."
573      RAD_HOME="$RAD_NODE2_HOME" rad node connect "$NODE1_NID@127.0.0.1:$E2E_PORT1" --timeout 10 2>&1 | sed 's/^/    /' || true
574      sleep 1
575  
576      # Seed the project on node 1 (should already be seeded from Phase 5 clone)
577      RAD_HOME="$RAD_NODE1_HOME" rad seed "$PROJECT_RID" 2>/dev/null || true
578  
579      # ── Device 1: signed commit + push patch ──────────────────────────
580      info "Device 1: creating a signed commit and pushing a patch..."
581      COMMIT1_OUTPUT=$(
582          cd "$PROJECT_DIR"
583          git config user.signingKey "auths:node1-key"
584          git checkout -b feature-device1 2>/dev/null
585          echo "Change from device 1" >> README.md
586          git add README.md
587          git commit -m "Signed commit from device 1" 2>&1
588      ) || {
589          echo -e "  ${RED}✗${NC} Device 1 signed commit failed"
590          echo "$COMMIT1_OUTPUT" | sed 's/^/    /'
591          phase_fail "device 1 signed commit"
592          PHASE7_OK=false
593      }
594  
595      if $PHASE7_OK; then
596          echo "$COMMIT1_OUTPUT" | sed 's/^/    /'
597  
598          info "Verifying device 1 commit signature..."
599          VERIFY1_OUTPUT=$(cd "$PROJECT_DIR" && git verify-commit HEAD 2>&1) || {
600              echo -e "  ${RED}✗${NC} Device 1 commit verification failed"
601              echo "$VERIFY1_OUTPUT" | sed 's/^/    /'
602              phase_fail "device 1 signature verification"
603              PHASE7_OK=false
604          }
605          if $PHASE7_OK; then
606              echo -e "  ${GREEN}✓${NC} Device 1 signed commit verified"
607          fi
608  
609          info "Pushing device 1 patch to Radicle..."
610          PUSH1_OUTPUT=$(
611              cd "$PROJECT_DIR"
612              export RAD_HOME="$RAD_NODE1_HOME"
613              git push rad HEAD:refs/patches 2>&1
614          ) || true
615          echo "$PUSH1_OUTPUT" | sed 's/^/    /'
616  
617          PATCH1_ID=$(echo "$PUSH1_OUTPUT" | grep -oE 'Patch [0-9a-f]{40} opened' | awk '{print $2}') || true
618          if [ -n "$PATCH1_ID" ]; then
619              PATCH1_URL="https://app.radicle.xyz/nodes/rosa.radicle.xyz/${PROJECT_RID}/patches/${PATCH1_ID}"
620              echo -e "  ${GREEN}✓${NC} Device 1 patch created: ${CYAN}${PATCH1_ID}${NC}"
621              echo -e "    ${DIM}URL: ${PATCH1_URL}${NC}"
622          else
623              echo -e "  ${YELLOW}⚠${NC} Could not extract device 1 patch ID"
624          fi
625      fi
626  
627      # ── Device 2: clone, signed commit + push patch ───────────────────
628      if $PHASE7_OK; then
629          NODE2_PROJECT="$DEMO_DIR/e2e-project-node2"
630  
631          info "Device 2: cloning project..."
632          RAD_HOME="$RAD_NODE2_HOME" rad clone "$PROJECT_RID" "$NODE2_PROJECT" --seed "$NODE1_NID" --timeout 15 2>&1 | sed 's/^/    /' || true
633  
634          if [ -d "$NODE2_PROJECT" ]; then
635              info "Device 2: creating a signed commit and pushing a patch..."
636              COMMIT2_OUTPUT=$(
637                  cd "$NODE2_PROJECT"
638                  git config user.name "E2E Tester Node2"
639                  git config user.email "e2e@test.local"
640                  git config gpg.format ssh
641                  git config gpg.ssh.program "$AUTHS_SIGN_BIN"
642                  git config gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS"
643                  git config commit.gpgsign true
644                  git config user.signingKey "auths:node2-key"
645                  git checkout -b feature-device2 2>/dev/null
646                  echo "Change from device 2" >> README.md
647                  git add README.md
648                  git commit -m "Signed commit from device 2" 2>&1
649              ) || {
650                  echo -e "  ${RED}✗${NC} Device 2 signed commit failed"
651                  echo "$COMMIT2_OUTPUT" | sed 's/^/    /'
652                  phase_fail "device 2 signed commit"
653                  PHASE7_OK=false
654              }
655  
656              if $PHASE7_OK; then
657                  echo "$COMMIT2_OUTPUT" | sed 's/^/    /'
658  
659                  info "Verifying device 2 commit signature..."
660                  VERIFY2_OUTPUT=$(cd "$NODE2_PROJECT" && git verify-commit HEAD 2>&1) || {
661                      echo -e "  ${RED}✗${NC} Device 2 commit verification failed"
662                      echo "$VERIFY2_OUTPUT" | sed 's/^/    /'
663                      phase_fail "device 2 signature verification"
664                      PHASE7_OK=false
665                  }
666                  if $PHASE7_OK; then
667                      echo -e "  ${GREEN}✓${NC} Device 2 signed commit verified"
668                  fi
669  
670                  info "Pushing device 2 patch to Radicle..."
671                  PUSH2_OUTPUT=$(
672                      cd "$NODE2_PROJECT"
673                      export RAD_HOME="$RAD_NODE2_HOME"
674                      git push rad HEAD:refs/patches 2>&1
675                  ) || true
676                  echo "$PUSH2_OUTPUT" | sed 's/^/    /'
677  
678                  PATCH2_ID=$(echo "$PUSH2_OUTPUT" | grep -oE 'Patch [0-9a-f]{40} opened' | awk '{print $2}') || true
679                  if [ -n "$PATCH2_ID" ]; then
680                      PATCH2_URL="https://app.radicle.xyz/nodes/rosa.radicle.xyz/${PROJECT_RID}/patches/${PATCH2_ID}"
681                      echo -e "  ${GREEN}✓${NC} Device 2 patch created: ${CYAN}${PATCH2_ID}${NC}"
682                      echo -e "    ${DIM}URL: ${PATCH2_URL}${NC}"
683                  else
684                      echo -e "  ${YELLOW}⚠${NC} Could not extract device 2 patch ID"
685                  fi
686              fi
687          else
688              echo -e "  ${YELLOW}⚠${NC} Node 2 clone failed — skipping device 2 patch"
689          fi
690      fi
691  
692      # ── Sync to rosa (best effort) ────────────────────────────────────
693      info "Syncing to rosa.radicle.xyz (best effort)..."
694      RAD_HOME="$RAD_NODE1_HOME" rad sync --announce "$PROJECT_RID" --seed "$ROSA_NID" --timeout 15 2>&1 | sed 's/^/    /' || true
695      RAD_HOME="$RAD_NODE2_HOME" rad sync --announce "$PROJECT_RID" --seed "$ROSA_NID" --timeout 15 2>&1 | sed 's/^/    /' || true
696  
697      # Print summary of patch URLs
698      echo ""
699      echo -e "  ${BOLD}Patch URLs (rosa.radicle.xyz — permissive public seed):${NC}"
700      [ -n "${PATCH1_URL:-}" ] && echo -e "    Device 1: ${CYAN}${PATCH1_URL}${NC}"
701      [ -n "${PATCH2_URL:-}" ] && echo -e "    Device 2: ${CYAN}${PATCH2_URL}${NC}"
702      echo -e "  ${DIM}Note: URLs require successful sync. If behind NAT, the seed may not be able to fetch from you.${NC}"
703  
704      # Verify at least device 1 pushed a patch
705      if [ -n "${PATCH1_ID:-}" ]; then
706          echo -e "  ${GREEN}✓${NC} At least one signed patch pushed successfully"
707      else
708          if $PHASE7_OK; then
709              phase_fail "no patches created"
710              PHASE7_OK=false
711          fi
712      fi
713  
714      # ── Stop nodes ─────────────────────────────────────────────────────
715      info "Stopping Radicle nodes..."
716      RAD_HOME="$RAD_NODE1_HOME" rad node stop 2>/dev/null || true
717      RAD_HOME="$RAD_NODE2_HOME" rad node stop 2>/dev/null || true
718  fi
719  
720  if $PHASE7_OK; then
721      phase_pass
722  fi
723  
724  # ══════════════════════════════════════════════════════════════════════════════
725  #  Phase 8 — Revoke device 2
726  # ══════════════════════════════════════════════════════════════════════════════
727  phase_start "Phase 8: Revoke device 2"
728  
729  info "Revoking node 2..."
730  REVOKE_OUTPUT=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device revoke \
731      --device-did "$NODE2_DID" \
732      --identity-key-alias identity-key \
733      --note "E2E revocation test" \
734      2>&1) || { echo "$REVOKE_OUTPUT" | sed 's/^/    /'; phase_fail "device revoke node2"; exit 1; }
735  echo "$REVOKE_OUTPUT" | sed 's/^/    /'
736  
737  # Without --include-revoked, node 2 should not appear
738  ACTIVE_DEVICES=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device list 2>/dev/null || true)
739  assert_contains     "node 1 still active"            "$ACTIVE_DEVICES" "$NODE1_DID"
740  assert_not_contains "node 2 not in active list"      "$ACTIVE_DEVICES" "$NODE2_DID"
741  
742  # With --include-revoked, node 2 should appear as revoked
743  ALL_DEVICES=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device list --include-revoked 2>/dev/null || true)
744  assert_contains "node 2 shows as revoked" "$ALL_DEVICES" "$NODE2_DID"
745  
746  info "All-devices list (including revoked):"
747  echo "$ALL_DEVICES" | sed 's/^/    /'
748  
749  # Verify device 1 still resolves to controller after revocation of device 2
750  info "Verifying device 1 still resolves post-revocation..."
751  POST_REVOKE_RESOLVE=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device resolve --device-did "$NODE1_DID" 2>&1 || true)
752  POST_REVOKE_DID=$(echo "$POST_REVOKE_RESOLVE" | tr -d '[:space:]')
753  if [ "$POST_REVOKE_DID" = "$CONTROLLER_DID" ]; then
754      echo -e "  ${GREEN}✓${NC} Device 1 still resolves to controller after device 2 revocation"
755  else
756      echo -e "  ${RED}✗${NC} Device 1 resolution changed after revocation: '$POST_REVOKE_DID'"
757  fi
758  
759  phase_pass
760  
761  # ══════════════════════════════════════════════════════════════════════════════
762  #  Phase 9 — HTTP API assertions (requires modified radicle-httpd)
763  # ══════════════════════════════════════════════════════════════════════════════
764  phase_start "Phase 9: HTTP API assertions"
765  
766  PHASE9_OK=true
767  HTTPD_PORT=17899
768  
769  info "Starting Radicle node 1 for HTTP API testing..."
770  RAD_HOME="$RAD_NODE1_HOME" rad node start -- --listen 0.0.0.0:$E2E_PORT1 2>&1 | sed 's/^/    /' || true
771  sleep 2
772  
773  # Detect httpd port from node config, fall back to default 8080
774  DETECTED_PORT=$(RAD_HOME="$RAD_NODE1_HOME" rad config 2>/dev/null | grep -oP '"port":\s*\K[0-9]+' | head -1 || echo "8080")
775  HTTPD_URL="http://127.0.0.1:${DETECTED_PORT}"
776  
777  info "Testing httpd at $HTTPD_URL ..."
778  
779  # Check if the modified httpd serves the delegates endpoint
780  USER_RESPONSE=$(curl -sf "$HTTPD_URL/api/v1/delegates/$CONTROLLER_DID" 2>/dev/null) || {
781      info "Modified delegates endpoint not available — skipping API assertions"
782      RAD_HOME="$RAD_NODE1_HOME" rad node stop 2>/dev/null || true
783      phase_pass
784      PHASE9_OK="skipped"
785  }
786  
787  if [ "$PHASE9_OK" = "true" ]; then
788      # ── Delegates endpoint assertions ──────────────────────────────────
789      info "GET /v1/delegates/$CONTROLLER_DID response:"
790      echo "$USER_RESPONSE" | sed 's/^/    /'
791  
792      assert_contains "user response has controllerDid" "$USER_RESPONSE" "controllerDid"
793      assert_contains "user response has isKeri: true"  "$USER_RESPONSE" '"isKeri":true'
794      assert_contains "user response has devices array" "$USER_RESPONSE" '"devices":'
795  
796      # ── KEL endpoint assertions ────────────────────────────────────────
797      info "Testing KEL endpoint..."
798      KEL_RESPONSE=$(curl -sf "$HTTPD_URL/api/v1/identity/$CONTROLLER_DID/kel" 2>/dev/null) || {
799          echo -e "  ${RED}✗${NC} KEL endpoint failed"
800          PHASE9_OK=false
801      }
802  
803      if [ "$PHASE9_OK" = "true" ]; then
804          # KEL should be a non-empty JSON array
805          assert_contains "KEL response is a JSON array" "$KEL_RESPONSE" "["
806          KEL_LENGTH=$(echo "$KEL_RESPONSE" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
807          if [ "$KEL_LENGTH" -gt 0 ]; then
808              echo -e "  ${GREEN}✓${NC} KEL contains $KEL_LENGTH events"
809          else
810              echo -e "  ${RED}✗${NC} KEL is empty"
811              PHASE9_OK=false
812          fi
813      fi
814  
815      # ── Attestations endpoint assertions ───────────────────────────────
816      info "Testing attestations endpoint..."
817      ATT_RESPONSE=$(curl -sf "$HTTPD_URL/api/v1/identity/$CONTROLLER_DID/attestations" 2>/dev/null) || {
818          echo -e "  ${RED}✗${NC} Attestations endpoint failed"
819          PHASE9_OK=false
820      }
821  
822      if [ "$PHASE9_OK" = "true" ]; then
823          assert_contains "attestations response is a JSON array" "$ATT_RESPONSE" "["
824          ATT_COUNT=$(echo "$ATT_RESPONSE" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
825  
826          # After Phase 8 revocation, we expect 1 active device (node1 active, node2 revoked).
827          # The attestation endpoint may return all attestations (including revoked) or only active.
828          if [ "$ATT_COUNT" -gt 0 ]; then
829              echo -e "  ${GREEN}✓${NC} Attestations endpoint returned $ATT_COUNT attestation(s)"
830          else
831              echo -e "  ${RED}✗${NC} Attestations response is empty"
832              PHASE9_OK=false
833          fi
834      fi
835  
836      # ── Stop node ──────────────────────────────────────────────────────
837      info "Stopping node..."
838      RAD_HOME="$RAD_NODE1_HOME" rad node stop 2>/dev/null || true
839  
840      if [ "$PHASE9_OK" = "true" ]; then
841          phase_pass
842      else
843          phase_fail "HTTP API assertions"
844      fi
845  fi
846  
847  # ══════════════════════════════════════════════════════════════════════════════
848  #  Summary
849  # ══════════════════════════════════════════════════════════════════════════════
850  echo ""
851  echo -e "${CYAN}╔════════════════════════════════════════════════════════════╗${NC}"
852  echo -e "${CYAN}║${NC}${BOLD}           Radicle Multi-Device E2E — Summary              ${NC}${CYAN}║${NC}"
853  echo -e "${CYAN}╚════════════════════════════════════════════════════════════╝${NC}"
854  echo ""
855  
856  FAILURES=0
857  for result in "${PHASE_RESULTS[@]}"; do
858      if [[ "$result" == PASS:* ]]; then
859          echo -e "  ${GREEN}✓${NC} ${result#PASS: }"
860      else
861          echo -e "  ${RED}✗${NC} ${result#FAIL: }"
862          FAILURES=$((FAILURES + 1))
863      fi
864  done
865  
866  echo ""
867  if [ "$FAILURES" -eq 0 ]; then
868      echo -e "${GREEN}${BOLD}All ${#PHASE_RESULTS[@]} phases passed.${NC}"
869      exit 0
870  else
871      echo -e "${RED}${BOLD}$FAILURES of ${#PHASE_RESULTS[@]} phases failed.${NC}"
872      exit 1
873  fi