From e1eb342edf103d938ff2f5d0c0148d410ae8e934 Mon Sep 17 00:00:00 2001 From: matst80 Date: Wed, 24 Jun 2026 12:46:52 +0200 Subject: [PATCH] more ucp --- api-tests/test_ucp.sh | 291 ++++++++++++++++++++++++++++++++++++++++++ cmd/cart/main.go | 13 +- cmd/checkout/main.go | 8 +- cmd/order/main.go | 8 +- 4 files changed, 314 insertions(+), 6 deletions(-) create mode 100755 api-tests/test_ucp.sh diff --git a/api-tests/test_ucp.sh b/api-tests/test_ucp.sh new file mode 100755 index 0000000..6f801e2 --- /dev/null +++ b/api-tests/test_ucp.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────────────── +# UCP API test suite +# Tests the Universal Commerce Protocol REST endpoints (cart, 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 +# +# # 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" = "--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}" +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:-}" +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 + + # ── Add item to cart ──────────────────────────────────────────────────────── + echo "--- 2.3 POST /ucp/v1/carts/{id}/items ---" + add_resp=$(curl -s -w '\n%{http_code}' -X POST "$CART_URL/ucp/v1/carts/$CART_ID/items" \ + -H 'Content-Type: application/json' \ + -d '{ + "sku": "TEST-SKU-1", + "name": "Test Product", + "quantity": 1, + "unitPrice": 19900, + "taxRate": 2500 + }') + add_code=$(echo "$add_resp" | tail -1) + add_body=$(echo "$add_resp" | sed '$d') + assert_status "Add item" 201 "$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 " total: $(echo "$get_cart_body" | sed -n 's/.*"total"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')" + + echo "" +fi + +# ══════════════════════════════════════════════════════════════════════════════ +# SECTION 3: Full flow — cart → checkout → order (via edge/nginx only) +# ══════════════════════════════════════════════════════════════════════════════ +if [ "$MODE" = "--edge" ] && [ -n "${CHECKOUT_URL:-}" ]; then + header "3. Full Flow (via nginx)" + + # ── This section needs the full compose stack with nginx on :8080, + # and checkout must be reachable. Skip for infra/direct modes. + echo "--- 3.1 Create checkout session ---" + 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" +fi + +# ══════════════════════════════════════════════════════════════════════════════ +# SECTION 4: Signature verification (standalone) +# ══════════════════════════════════════════════════════════════════════════════ +if [ "$MODE" = "--verify-sig" ]; then + header "4. 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 "--- 4.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 "═══════════════════════════════════════════════════════════════" diff --git a/cmd/cart/main.go b/cmd/cart/main.go index e2e9c78..7ecce73 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -305,8 +305,17 @@ func main() { cartUCP = ucp.WithSigning(cartUCP, signer) log.Print("ucp signing enabled") } - mux.Handle("/ucp/v1/carts", cartUCP) - mux.Handle("/ucp/v1/carts/", cartUCP) + // StripPrefix is required because the sub-mux (CartHandler) registers + // routes like "GET /{id}" which expect a relative path; Go's ServeMux + // passes the full request path (e.g. /ucp/v1/carts/123) without stripping + // the prefix, so the sub-mux would never match. + // + // Only the subtree pattern (/ucp/v1/carts/) is registered, NOT the exact + // pattern (/ucp/v1/carts), because registering both causes Go's ServeMux + // to redirect the exact match to the subtree root, and StripPrefix + // interferes with the redirect target (producing a 307 to "/" instead + // of "/ucp/v1/carts/"). Consumers should use the trailing-slash variant. + mux.Handle("/ucp/v1/carts/", http.StripPrefix("/ucp/v1/carts", cartUCP)) // Stateless promotion evaluation: POST a (possibly partial) eval context and // get back the resulting totals plus the applied/pending effect breakdown, diff --git a/cmd/checkout/main.go b/cmd/checkout/main.go index 95311a7..6eba414 100644 --- a/cmd/checkout/main.go +++ b/cmd/checkout/main.go @@ -194,8 +194,12 @@ func main() { checkoutUCP = ucp.WithSigning(checkoutUCP, signer) log.Print("ucp signing enabled") } - mux.Handle("/ucp/v1/checkout-sessions", checkoutUCP) - mux.Handle("/ucp/v1/checkout-sessions/", checkoutUCP) + // StripPrefix is required because the sub-mux (CheckoutHandler) registers + // routes like "GET /{id}" which expect a relative path; Go's ServeMux + // passes the full request path without stripping the prefix. + // Only the subtree pattern is registered (see comment in cmd/cart/main.go + // for why the exact-match pattern is omitted). + mux.Handle("/ucp/v1/checkout-sessions/", http.StripPrefix("/ucp/v1/checkout-sessions", checkoutUCP)) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { grainCount, capacity := pool.LocalUsage() diff --git a/cmd/order/main.go b/cmd/order/main.go index 00f3693..a88c733 100644 --- a/cmd/order/main.go +++ b/cmd/order/main.go @@ -162,8 +162,12 @@ func main() { orderUCP = ucp.WithSigning(orderUCP, signer) logger.Info("ucp signing enabled") } - mux.Handle("/ucp/v1/orders", orderUCP) - mux.Handle("/ucp/v1/orders/", orderUCP) + // StripPrefix is required because the sub-mux (OrderHandler) registers + // routes like "GET /{id}" which expect a relative path; Go's ServeMux + // passes the full request path without stripping the prefix. + // Only the subtree pattern is registered (see comment in cmd/cart/main.go + // for why the exact-match pattern is omitted). + mux.Handle("/ucp/v1/orders/", http.StripPrefix("/ucp/v1/orders", orderUCP)) // Saga / flow admin (backoffice editor). mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)