#!/usr/bin/env bash # ────────────────────────────────────────────────────────────────────────────── # UCP API test suite # Tests the Universal Commerce Protocol REST endpoints (cart, checkout, order) # via curl. # # Prerequisites: # make up-infra (infra compose up — order :8092, cart :8090) # OR full stack (docker compose --profile "*" up — nginx on :8080) # # Usage: # # Direct to Docker services (infra stack): # bash api-tests/test_ucp.sh --infra # # # Via nginx (full compose stack): # bash api-tests/test_ucp.sh --edge # # # Single service: # bash api-tests/test_ucp.sh --order # bash api-tests/test_ucp.sh --cart # bash api-tests/test_ucp.sh --checkout # # # Verify signatures (requires signing key mounted): # bash api-tests/test_ucp.sh --verify-sig # ────────────────────────────────────────────────────────────────────────────── set -euo pipefail MODE="${1:---infra}" JQ="${JQ:-$(command -v jq || true)}" # ── Base URLs ──────────────────────────────────────────────────────────────── if [ "$MODE" = "--edge" ]; then BASE="http://localhost:8080" CART_URL="$BASE" ORDER_URL="$BASE" CHECKOUT_URL="$BASE" elif [ "$MODE" = "--order" ]; then ORDER_URL="http://localhost:8092" elif [ "$MODE" = "--cart" ]; then CART_URL="http://localhost:8090" elif [ "$MODE" = "--checkout" ]; then CART_URL="http://localhost:8090" CHECKOUT_URL="http://localhost:8094" elif [ "$MODE" = "--verify-sig" ]; then # If signing is enabled (key mounted), you need the full stack or direct with # env var — try both common ports. ORDER_URL="${ORDER_URL:-http://localhost:8092}" CART_URL="${CART_URL:-http://localhost:8090}" CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:8094}" else # Default: infra stack, direct to Docker ports CART_URL="${CART_URL:-http://localhost:8090}" ORDER_URL="${ORDER_URL:-http://localhost:8092}" CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:8094}" fi echo "═══════════════════════════════════════════════════════════════" echo " UCP API Test Suite — mode: $MODE" echo " CART: ${CART_URL:-"(unreachable)"}" echo " ORDER: ${ORDER_URL:-"(unreachable)"}" echo " CHECK: ${CHECKOUT_URL:-"(unreachable)"}" echo "═══════════════════════════════════════════════════════════════" echo "" # ── Helpers ────────────────────────────────────────────────────────────────── pass() { echo " ✅ $1"; } fail() { echo " ❌ $1"; } header() { echo ""; echo "─── $1 ───"; } check_jq() { if [ -z "$JQ" ]; then echo " ⚠️ jq not installed — showing raw JSON instead" fi } maybe_jq() { if [ -n "$JQ" ]; then echo "$1" | jq "$2" 2>/dev/null || echo "$1" | head -c 200 else echo "$1" | head -c 200 fi } assert_status() { local label="$1" expected="$2" actual="$3" body="$4" if [ "$actual" -eq "$expected" ]; then pass "$label (HTTP $actual)" else fail "$label — expected HTTP $expected, got $actual" echo " body: $(echo "$body" | head -c 300)" fi } # ══════════════════════════════════════════════════════════════════════════════ # SECTION 1: Order service tests # ══════════════════════════════════════════════════════════════════════════════ if [ -n "${ORDER_URL:-}" ]; then header "1. Order Service → $ORDER_URL" # ── Health ────────────────────────────────────────────────────────────────── echo "--- 1.1 Health check ---" resp=$(curl -sf "$ORDER_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp" # ── Create order via checkout ─────────────────────────────────────────────── echo "--- 1.2 Create order via POST /checkout ---" create_resp=$(curl -s -w '\n%{http_code}' -X POST "$ORDER_URL/checkout" \ -H 'Content-Type: application/json' \ -d '{ "orderReference": "ucp-test-1", "currency": "SEK", "lines": [ { "reference": "l1", "sku": "TEST-SKU-1", "name": "Test Product", "quantity": 2, "unitPrice": 19900, "taxRate": 2500 } ] }') create_code=$(echo "$create_resp" | tail -1) create_body=$(echo "$create_resp" | sed '$d') assert_status "Create order" 201 "$create_code" "$create_body" ORDER_ID=$(echo "$create_body" | sed -n 's/.*"orderId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') if [ -n "$ORDER_ID" ]; then pass "Order ID: $ORDER_ID" else ORDER_ID="1" fail "Could not extract order ID, using '$ORDER_ID'" echo " response: $(echo "$create_body" | head -c 400)" fi # ── Read order via UCP ────────────────────────────────────────────────────── echo "--- 1.3 GET /ucp/v1/orders/{id} ---" get_resp=$(curl -s -w '\n%{http_code}' "$ORDER_URL/ucp/v1/orders/$ORDER_ID") get_code=$(echo "$get_resp" | tail -1) get_body=$(echo "$get_resp" | sed '$d') assert_status "GET UCP order" 200 "$get_code" "$get_body" echo " order: $(maybe_jq "$get_body" '.data.orderId + " status=" + .data.status')" # ── Cancel order ──────────────────────────────────────────────────────────── echo "--- 1.4 POST /ucp/v1/orders/{id}/cancel ---" cancel_resp=$(curl -s -w '\n%{http_code}' -X POST "$ORDER_URL/ucp/v1/orders/$ORDER_ID/cancel" \ -H 'Content-Type: application/json' \ -d '{"reason": "test cancellation"}') cancel_code=$(echo "$cancel_resp" | tail -1) cancel_body=$(echo "$cancel_resp" | sed '$d') # May be 200 (cancelled) or 422 (already cancelled by flow) — both ok if [ "$cancel_code" = "200" ] || [ "$cancel_code" = "422" ]; then pass "Cancel order (HTTP $cancel_code)" echo " response: $(echo "$cancel_body" | head -c 200)" if [ "$cancel_code" = "200" ]; then ORDER_CANCELLED=true fi else fail "Cancel order — expected 200 or 422, got $cancel_code" echo " body: $(echo "$cancel_body" | head -c 300)" fi # ── 404 for non-existent order ────────────────────────────────────────────── echo "--- 1.5 GET non-existent order (404) ---" notfound_resp=$(curl -s -w '\n%{http_code}' "$ORDER_URL/ucp/v1/orders/nonexistent") notfound_code=$(echo "$notfound_resp" | tail -1) notfound_body=$(echo "$notfound_resp" | sed '$d') assert_status "GET non-existent" 404 "$notfound_code" "$notfound_body" # ── Signature headers (if enabled) ────────────────────────────────────────── echo "--- 1.6 Check signature headers ---" sig_resp=$(curl -s -i "$ORDER_URL/ucp/v1/orders/$ORDER_ID" 2>/dev/null | head -30) if echo "$sig_resp" | grep -qi "signature-input"; then pass "Signature-Input header present" echo " $(echo "$sig_resp" | grep -i "signature-input" | head -1)" else echo " ℹ️ No Signature-Input header (signing key not mounted — expected in infra stack)" fi if echo "$sig_resp" | grep -qi "^signature:"; then pass "Signature header present" else echo " ℹ️ No Signature header" fi echo "" fi # ══════════════════════════════════════════════════════════════════════════════ # SECTION 2: Cart service tests # ══════════════════════════════════════════════════════════════════════════════ if [ -n "${CART_URL:-}" ]; then header "2. Cart Service → $CART_URL" # ── Health ────────────────────────────────────────────────────────────────── echo "--- 2.1 Health check ---" resp=$(curl -sf "$CART_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp" # ── Create cart via UCP ───────────────────────────────────────────────────── echo "--- 2.2 POST /ucp/v1/carts/ (create cart) ---" # Note: must use trailing slash — Go's ServeMux with StripPrefix only # registers the subtree pattern (/ucp/v1/carts/) to avoid a redirect bug. cart_resp=$(curl -s -w '\n%{http_code}' -X POST "$CART_URL/ucp/v1/carts/" \ -H 'Content-Type: application/json' \ -d '{ "currency": "SEK", "country": "se", "locale": "sv-SE" }') cart_code=$(echo "$cart_resp" | tail -1) cart_body=$(echo "$cart_resp" | sed '$d') assert_status "Create cart" 201 "$cart_code" "$cart_body" CART_ID=$(echo "$cart_body" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') if [ -n "$CART_ID" ]; then pass "Cart ID: $CART_ID" else CART_ID="1" fail "Could not extract cart ID, using '$CART_ID'" echo " response: $(echo "$cart_body" | head -c 400)" fi # ── Replace cart contents via UCP ─────────────────────────────────────────── echo "--- 2.3 PUT /ucp/v1/carts/{id} ---" add_resp=$(curl -s -w '\n%{http_code}' -X PUT "$CART_URL/ucp/v1/carts/$CART_ID" \ -H 'Content-Type: application/json' \ -d '{ "country": "se", "items": [ { "sku": "TEST-SKU-1", "name": "Test Product", "quantity": 1, "price": 19900, "taxRate": 25 } ] }') add_code=$(echo "$add_resp" | tail -1) add_body=$(echo "$add_resp" | sed '$d') assert_status "Update cart items" 200 "$add_code" "$add_body" # ── Get cart ──────────────────────────────────────────────────────────────── echo "--- 2.4 GET /ucp/v1/carts/{id} ---" get_cart_resp=$(curl -s -w '\n%{http_code}' "$CART_URL/ucp/v1/carts/$CART_ID") get_cart_code=$(echo "$get_cart_resp" | tail -1) get_cart_body=$(echo "$get_cart_resp" | sed '$d') assert_status "Get cart" 200 "$get_cart_code" "$get_cart_body" echo " totalIncVat: $(echo "$get_cart_body" | sed -n 's/.*"totalIncVat"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')" echo "" fi # ══════════════════════════════════════════════════════════════════════════════ # SECTION 3: Checkout service tests # ══════════════════════════════════════════════════════════════════════════════ if [ -n "${CHECKOUT_URL:-}" ] && [ -n "${CART_ID:-}" ]; then header "3. Checkout Service → $CHECKOUT_URL" # ── Health ────────────────────────────────────────────────────────────────── echo "--- 3.1 Health check ---" resp=$(curl -sf "$CHECKOUT_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp" # ── Create checkout session ──────────────────────────────────────────────── echo "--- 3.2 POST /ucp/v1/checkout-sessions/ ---" checkout_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions/" \ -H 'Content-Type: application/json' \ -d '{ "cartId": "'"$CART_ID"'", "currency": "SEK", "country": "se" }') checkout_code=$(echo "$checkout_resp" | tail -1) checkout_body=$(echo "$checkout_resp" | sed '$d') assert_status "Create checkout session" 201 "$checkout_code" "$checkout_body" CHECKOUT_ID=$(echo "$checkout_body" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') if [ -n "$CHECKOUT_ID" ]; then pass "Checkout ID: $CHECKOUT_ID" else fail "Could not extract checkout ID" echo " response: $(echo "$checkout_body" | head -c 400)" fi if [ -n "${CHECKOUT_ID:-}" ]; then # ── Read checkout session ──────────────────────────────────────────────── echo "--- 3.3 GET /ucp/v1/checkout-sessions/{id} ---" get_checkout_resp=$(curl -s -w '\n%{http_code}' "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID") get_checkout_code=$(echo "$get_checkout_resp" | tail -1) get_checkout_body=$(echo "$get_checkout_resp" | sed '$d') assert_status "Get checkout session" 200 "$get_checkout_code" "$get_checkout_body" echo " checkout: $(maybe_jq "$get_checkout_body" '.id + " status=" + .status')" # ── Complete checkout session ──────────────────────────────────────────── echo "--- 3.4 POST /ucp/v1/checkout-sessions/{id}/complete ---" complete_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID/complete" \ -H 'Content-Type: application/json' \ -d '{ "provider": "test", "paymentToken": "ucp-test-payment-token", "currency": "SEK", "country": "se" }') complete_code=$(echo "$complete_resp" | tail -1) complete_body=$(echo "$complete_resp" | sed '$d') assert_status "Complete checkout session" 200 "$complete_code" "$complete_body" COMPLETE_ORDER_ID=$(echo "$complete_body" | sed -n 's/.*"orderId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') if [ -n "$COMPLETE_ORDER_ID" ]; then pass "Checkout produced order ID: $COMPLETE_ORDER_ID" else fail "Checkout completion did not return an orderId" echo " response: $(echo "$complete_body" | head -c 400)" fi # ── Signature headers (if enabled) ────────────────────────────────────── echo "--- 3.5 Check signature headers ---" checkout_sig_resp=$(curl -s -i "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID" 2>/dev/null | head -30) if echo "$checkout_sig_resp" | grep -qi "signature-input"; then pass "Checkout Signature-Input header present" echo " $(echo "$checkout_sig_resp" | grep -i "signature-input" | head -1)" else echo " ℹ️ No Signature-Input header" fi if echo "$checkout_sig_resp" | grep -qi "^signature:"; then pass "Checkout Signature header present" else echo " ℹ️ No Signature header" fi fi fi # ══════════════════════════════════════════════════════════════════════════════ # SECTION 4: Edge-only discovery artifact tests # ══════════════════════════════════════════════════════════════════════════════ if [ "$MODE" = "--edge" ]; then header "4. Edge Discovery → $BASE" echo "--- 4.1 GET /.well-known/ucp ---" profile_resp=$(curl -s -w '\n%{http_code}' "$BASE/.well-known/ucp") profile_code=$(echo "$profile_resp" | tail -1) profile_body=$(echo "$profile_resp" | sed '$d') assert_status "Get UCP profile" 200 "$profile_code" "$profile_body" if echo "$profile_body" | grep -q '/.well-known/ucp/openapi/shopping-rest.openapi.json'; then pass "Profile advertises self-hosted REST OpenAPI" else fail "Profile is missing self-hosted REST OpenAPI URL" fi echo "--- 4.2 GET /.well-known/ucp/openapi/shopping-rest.openapi.json ---" openapi_resp=$(curl -s -w '\n%{http_code}' "$BASE/.well-known/ucp/openapi/shopping-rest.openapi.json") openapi_code=$(echo "$openapi_resp" | tail -1) openapi_body=$(echo "$openapi_resp" | sed '$d') assert_status "Get UCP REST OpenAPI" 200 "$openapi_code" "$openapi_body" if echo "$openapi_body" | grep -q '"openapi"'; then pass "OpenAPI document returned" else fail "OpenAPI document body missing openapi field" fi echo "--- 4.3 GET self-hosted UCP schemas ---" for schema_path in \ "/.well-known/ucp/schemas/shopping/customer.json" \ "/.well-known/ucp/schemas/shopping/authentication.json" \ "/.well-known/ucp/schemas/payments/processor_tokenizer.json" do schema_resp=$(curl -s -w '\n%{http_code}' "$BASE$schema_path") schema_code=$(echo "$schema_resp" | tail -1) schema_body=$(echo "$schema_resp" | sed '$d') assert_status "Get $schema_path" 200 "$schema_code" "$schema_body" if echo "$schema_body" | grep -q '"$schema"'; then pass "$schema_path is valid schema JSON" else fail "$schema_path body missing \$schema" fi done fi # ══════════════════════════════════════════════════════════════════════════════ # SECTION 5: Signature verification (standalone) # ══════════════════════════════════════════════════════════════════════════════ if [ "$MODE" = "--verify-sig" ]; then header "5. Signature Verification" # Verify that the response includes RFC 9421 HTTP Message Signatures. # Requires the service to have UCP_SIGNING_KEY_PATH set and the key mounted. echo "--- 5.1 Check Signed Response ---" sig_output=$(curl -s -D - "$ORDER_URL/ucp/v1/orders/$ORDER_ID" 2>/dev/null | head -40) echo "$sig_output" | while IFS= read -r line; do if echo "$line" | grep -qiE "(signature-input|signature:|x-ucp-timestamp|content-type)"; then echo " $line" fi done echo "" echo " To manually verify:" echo " 1. Extract the Signature-Input and Signature headers" echo " 2. Reconstruct the signature base string per RFC 9421 §2.2" echo " 3. Verify using the public key (x/y in docs/ucp-profile.json)" echo "" fi # ══════════════════════════════════════════════════════════════════════════════ echo "═══════════════════════════════════════════════════════════════" echo " Done." echo "" echo " Note: devProxies for /ucp/v1/* are now in islands.config.json." echo " Run 'make dev' for same-origin proxied access." echo "═══════════════════════════════════════════════════════════════"