Created
June 11, 2026 20:08
-
-
Save Gavinok/ad647ab31a0a0c3503fa4ecec605d57e to your computer and use it in GitHub Desktop.
test end to end revocation in the BC Wallet Showcase
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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