Skip to content

Instantly share code, notes, and snippets.

@Gavinok
Created June 11, 2026 20:08
Show Gist options
  • Select an option

  • Save Gavinok/ad647ab31a0a0c3503fa4ecec605d57e to your computer and use it in GitHub Desktop.

Select an option

Save Gavinok/ad647ab31a0a0c3503fa4ecec605d57e to your computer and use it in GitHub Desktop.
test end to end revocation in the BC Wallet Showcase
#!/usr/bin/env bash
# E2E revocation test: spin up an ACA-Py "Alice" holder agent, connect to
# the showcase server (backed by Traction), issue a revocable credential,
# then revoke it via the server API and verify.
#
# Prerequisites:
# - Docker running
# - ngrok installed and authenticated
# Traction is a cloud-hosted agent and cannot reach localhost. ngrok
# creates a public HTTPS tunnel to Alice's DIDComm transport port so
# Traction can deliver DIDComm messages (connection responses, credentials).
# - Server running on localhost:5000 (yarn dev)
# - A revocable credential seeded (with cred_def_id on the Credential doc)
# - WEBHOOK_SECRET env var matching server config (default: "secret")
#
# Usage: ./scripts/test-revocation-e2e.sh [base-url]
#
# base-url defaults to http://localhost:5000/digital-trust/showcase
#
# Image note:
# Traction issues credentials using the AnonCreds format
# (anoncreds/credential-offer@v1.0). Standard ACA-Py images only understand
# the hlindy/ format and will fail with "Unable to create credential request.
# No supported formats". This script uses the OWF image
# (ghcr.io/openwallet-foundation/acapy-agent:py3.13-1.6.0) which registers
# the anoncreds/ format handler, paired with --wallet-type askar-anoncreds.
#
# Webhook note:
# Traction cannot POST webhooks to localhost in local dev. After issuance
# this script reads rev_reg_id/cred_rev_id from Alice's wallet and manually
# POSTs the credential-issued webhook to the server. This creates the
# IssuedCredential record the revocation API requires.
set -euo pipefail
BASE_URL="${1:-http://localhost:5000/digital-trust/showcase}"
API="$BASE_URL/demo"
ALICE_ADMIN="http://localhost:8010"
ALICE_TRANSPORT_PORT=8011
ALICE_CONTAINER="alice-e2e-revocation"
WEBHOOK_SECRET="${WEBHOOK_SECRET:-secret}"
NGROK_PID=""
pass() { echo " PASS: $*"; }
fail() { echo " FAIL: $*"; exit 1; }
section() { echo; echo "-- $* --"; }
cleanup() {
echo
echo "Cleaning up..."
[ -n "$NGROK_PID" ] && kill "$NGROK_PID" 2>/dev/null || true
docker rm -f "$ALICE_CONTAINER" 2>/dev/null || true
}
trap cleanup EXIT
# --------------------------------------------------------------------------
# 0. Find a revocable credential on the server
# --------------------------------------------------------------------------
section "Find revocable credential"
CREDENTIALS=$(curl -sf "$API/credentials/" || fail "cannot reach server at $API")
CRED_JSON=$(echo "$CREDENTIALS" | jq -e '
[ .[] | select(.cred_def_id != null) ] | first
' 2>/dev/null) || fail "no credential with cred_def_id found"
CRED_DEF_ID=$(echo "$CRED_JSON" | jq -r '.cred_def_id')
CRED_NAME=$(echo "$CRED_JSON" | jq -r '.name')
CRED_ATTRS=$(echo "$CRED_JSON" | jq -c '.attributes')
echo " Using credential: $CRED_NAME"
echo " cred_def_id: $CRED_DEF_ID"
# Build credential_preview attributes -- use the seeded default value if present,
# otherwise fall back to the attribute name as a placeholder.
PREVIEW_ATTRS=$(echo "$CRED_ATTRS" | jq -c '
[ .[] | { name: .name, value: ((.value // .name // "test") | tostring) } ]
')
echo " attributes: $PREVIEW_ATTRS"
# --------------------------------------------------------------------------
# 1. Start ngrok tunnel for Alice's DIDComm transport
# --------------------------------------------------------------------------
section "Start ngrok tunnel for Alice"
# Kill any existing ngrok on this port to avoid "tunnel already registered" errors.
pkill -f "ngrok http $ALICE_TRANSPORT_PORT" 2>/dev/null || true
sleep 1
ngrok http "$ALICE_TRANSPORT_PORT" --log=stdout --log-level=warn > /tmp/ngrok-alice.log 2>&1 &
NGROK_PID=$!
sleep 3
ALICE_ENDPOINT=$(curl -sf http://localhost:4040/api/tunnels \
| jq -r '.tunnels[] | select(.proto=="https") | .public_url')
[ -n "$ALICE_ENDPOINT" ] || fail "ngrok tunnel not available -- is ngrok authenticated?"
echo " Alice endpoint: $ALICE_ENDPOINT"
# --------------------------------------------------------------------------
# 2. Start Alice ACA-Py holder agent
# --------------------------------------------------------------------------
section "Start Alice ACA-Py agent"
docker rm -f "$ALICE_CONTAINER" 2>/dev/null || true
# --network=host: on Linux, host.docker.internal does not resolve inside
# containers by default. host networking makes Alice bind directly on host
# ports so ngrok can forward to her DIDComm transport.
#
# wallet-type askar-anoncreds: required to handle anoncreds/credential-offer@v1.0
# format sent by Traction. askar (default) only handles hlindy/ format.
#
# wallet-name includes timestamp to avoid conflicts across test runs.
docker run --rm -d \
--name "$ALICE_CONTAINER" \
--network=host \
ghcr.io/openwallet-foundation/acapy-agent:py3.13-1.6.0 \
start \
--label Alice \
--inbound-transport http 0.0.0.0 "$ALICE_TRANSPORT_PORT" \
--outbound-transport http \
--endpoint "$ALICE_ENDPOINT" \
--genesis-url http://test.bcovrin.vonx.io/genesis \
--wallet-type askar-anoncreds \
--wallet-name "alice_e2e_$(date +%s)" \
--wallet-key alice_secret \
--auto-provision \
--admin 0.0.0.0 8010 \
--admin-insecure-mode \
--log-level info \
--auto-accept-invites \
--auto-accept-requests \
--auto-ping-connection \
--auto-store-credential \
--auto-respond-credential-offer \
--auto-respond-credential-request \
--auto-respond-presentation-request \
--auto-verify-presentation
echo " Waiting for Alice to start..."
for i in $(seq 1 30); do
if curl -sf "$ALICE_ADMIN/status" >/dev/null 2>&1; then
break
fi
sleep 2
done
curl -sf "$ALICE_ADMIN/status" >/dev/null || fail "Alice agent did not start"
pass "Alice agent running"
# --------------------------------------------------------------------------
# 3. Create connection invitation from server, Alice receives it
# --------------------------------------------------------------------------
section "Establish connection"
INVITE_RESPONSE=$(curl -sf -X POST "$API/connections/createInvite" \
-H "Content-Type: application/json" \
-d '{}')
INVITATION_URL=$(echo "$INVITE_RESPONSE" | jq -r '.invitation_url // empty')
[ -n "$INVITATION_URL" ] || fail "no invitation_url in response"
echo " Invitation created"
# The server sets "goal" on the OOB invitation without "goal_code". ACA-Py
# rejects this with: {"_schema": ["Model cannot have goal without goal_code"]}
# Strip both fields before passing to Alice.
INVITATION=$(echo "$INVITE_RESPONSE" | jq '.invitation | del(.goal, .goal_code)')
curl -sf -X POST "$ALICE_ADMIN/out-of-band/receive-invitation" \
-H "Content-Type: application/json" \
-d "$INVITATION" > /dev/null
echo " Alice received invitation"
# Poll server for active connection using the invitation's @id.
# The server proxies to Traction which tracks the connection by invitation_msg_id.
INV_MSG_ID=$(echo "$INVITE_RESPONSE" | jq -r '.invitation["@id"] // .invitation.id // empty')
[ -n "$INV_MSG_ID" ] || fail "cannot extract invitation @id"
echo " Waiting for connection to become active..."
SERVER_CONN_ID=""
STATE=""
for i in $(seq 1 30); do
CONN_RESULT=$(curl -sf "$API/connections/invitationId/$INV_MSG_ID" 2>/dev/null || echo '{}')
STATE=$(echo "$CONN_RESULT" | jq -r '.state // empty')
if [ "$STATE" = "active" ] || [ "$STATE" = "completed" ]; then
SERVER_CONN_ID=$(echo "$CONN_RESULT" | jq -r '.connection_id')
break
fi
sleep 2
done
[ -n "$SERVER_CONN_ID" ] || fail "connection did not reach active state (last state: $STATE)"
pass "Connection active (id: $SERVER_CONN_ID)"
# --------------------------------------------------------------------------
# 4. Issue credential to Alice
# --------------------------------------------------------------------------
section "Issue credential"
# filter.anoncreds matches Traction's issuer format. Using filter.indy here
# causes a 400 from Traction -- it only accepts anoncreds format.
OFFER_PAYLOAD=$(jq -n \
--arg conn_id "$SERVER_CONN_ID" \
--arg cred_def_id "$CRED_DEF_ID" \
--argjson attrs "$PREVIEW_ATTRS" \
'{
connection_id: $conn_id,
auto_issue: true,
auto_remove: false,
trace: false,
filter: { anoncreds: { cred_def_id: $cred_def_id } },
credential_preview: { attributes: $attrs }
}')
OFFER_RESULT=$(curl -sf -X POST "$API/credentials/offerCredential" \
-H "Content-Type: application/json" \
-d "$OFFER_PAYLOAD") || fail "credential offer failed"
CRED_EX_ID=$(echo "$OFFER_RESULT" | jq -r '.cred_ex_id // empty')
[ -n "$CRED_EX_ID" ] || fail "no cred_ex_id in offer response"
echo " Credential offer sent (cred_ex_id: $CRED_EX_ID)"
# Poll Alice's wallet until the credential appears. With auto-respond flags
# enabled, Alice handles the offer -> request -> store flow automatically.
echo " Waiting for issuance to complete..."
CRED_COUNT=0
ALICE_CREDS='{"results":[]}'
for i in $(seq 1 30); do
sleep 2
ALICE_CREDS=$(curl -sf "$ALICE_ADMIN/credentials" 2>/dev/null || echo '{"results":[]}')
CRED_COUNT=$(echo "$ALICE_CREDS" | jq '.results | length')
if [ "$CRED_COUNT" -gt 0 ]; then
break
fi
done
[ "$CRED_COUNT" -gt 0 ] || fail "Alice did not receive the credential"
pass "Credential issued and stored in Alice's wallet"
echo " Alice's credentials:"
echo "$ALICE_CREDS" | jq '.results[] | { referent, schema_id, cred_def_id }'
# --------------------------------------------------------------------------
# 5. Create IssuedCredential record via webhook simulation
# --------------------------------------------------------------------------
section "Create IssuedCredential record"
# Traction cannot POST webhooks to localhost in local dev. Normally the
# credential-issued webhook creates the IssuedCredential document in MongoDB
# (RevocationService.handleCredentialIssued), which is required before
# revocation can proceed.
#
# Workaround: read rev_reg_id and cred_rev_id from Alice's stored credential
# (Alice receives these from Traction as part of the issuance flow), then
# POST the webhook payload directly to the server ourselves.
ALICE_CRED=$(echo "$ALICE_CREDS" | jq '.results[0]')
REV_REG_ID=$(echo "$ALICE_CRED" | jq -r '.rev_reg_id // empty')
CRED_REV_ID=$(echo "$ALICE_CRED" | jq -r '.cred_rev_id // empty')
ALICE_CRED_DEF_ID=$(echo "$ALICE_CRED" | jq -r '.cred_def_id // empty')
[ -n "$REV_REG_ID" ] || fail "credential has no rev_reg_id -- is the cred def revocable?"
echo " rev_reg_id: $REV_REG_ID"
echo " cred_rev_id: $CRED_REV_ID"
WEBHOOK_PAYLOAD=$(jq -n \
--arg cred_ex_id "$CRED_EX_ID" \
--arg conn_id "$SERVER_CONN_ID" \
--arg rev_reg_id "$REV_REG_ID" \
--arg cred_rev_id "$CRED_REV_ID" \
--arg cred_def_id "$ALICE_CRED_DEF_ID" \
'{
cred_ex_id: $cred_ex_id,
connection_id: $conn_id,
state: "credential-issued",
revoc_reg_id: $rev_reg_id,
revocation_id: $cred_rev_id,
by_format: {
cred_issue: {
anoncreds: {
cred_def_id: $cred_def_id
}
}
}
}')
# x-api-key must match WEBHOOK_SECRET on the server (default: "secret")
WEBHOOK_RESULT=$(curl -sf -X POST "$BASE_URL/demo/whook/topic/issue_credential_v2_0" \
-H "Content-Type: application/json" \
-H "x-api-key: $WEBHOOK_SECRET" \
-d "$WEBHOOK_PAYLOAD") || fail "webhook simulation failed"
echo " Webhook delivered: $WEBHOOK_RESULT"
sleep 1
ISSUED_RECORDS=$(curl -sf "$API/revocations/?connection_id=$SERVER_CONN_ID" 2>/dev/null || echo '[]')
ISSUED_COUNT=$(echo "$ISSUED_RECORDS" | jq 'length')
[ "$ISSUED_COUNT" -gt 0 ] || fail "IssuedCredential record not created after webhook"
pass "IssuedCredential record created (count: $ISSUED_COUNT)"
# --------------------------------------------------------------------------
# 6. Revoke the credential via server API
# --------------------------------------------------------------------------
section "Revoke credential"
# POST /revocations/ looks up the IssuedCredential by cred_ex_id, calls
# Traction's /anoncreds/revocation/revoke endpoint, then marks the record
# status=revoked. Both the DB update and the Traction call must succeed.
REVOKE_RESULT=$(curl -sf -X POST "$API/revocations/" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg id "$CRED_EX_ID" '{ cred_ex_id: $id }')" 2>&1) \
|| fail "revocation request failed: $REVOKE_RESULT"
REVOKE_STATUS=$(echo "$REVOKE_RESULT" | jq -r '.status // empty')
[ "$REVOKE_STATUS" = "revoked" ] || fail "expected status 'revoked', got '$REVOKE_STATUS'"
pass "Credential revoked successfully"
echo "$REVOKE_RESULT" | jq '{ _id, status, revoked_at, connection_id }'
# --------------------------------------------------------------------------
# 7. Verify: double-revoke should fail
# --------------------------------------------------------------------------
section "Verify double-revoke is rejected"
# RevocationService.validateRevocation returns "credential already revoked"
# when status=revoked, which the controller surfaces as a 500.
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API/revocations/" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg id "$CRED_EX_ID" '{ cred_ex_id: $id }')")
if [ "$HTTP_CODE" -ge 400 ]; then
pass "Double-revoke rejected (HTTP $HTTP_CODE)"
else
fail "Double-revoke should have failed but got HTTP $HTTP_CODE"
fi
# --------------------------------------------------------------------------
# Done
# --------------------------------------------------------------------------
echo
echo "=== All tests passed ==="
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment