more ucp
This commit is contained in:
Executable
+291
@@ -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 "═══════════════════════════════════════════════════════════════"
|
||||||
+11
-2
@@ -305,8 +305,17 @@ func main() {
|
|||||||
cartUCP = ucp.WithSigning(cartUCP, signer)
|
cartUCP = ucp.WithSigning(cartUCP, signer)
|
||||||
log.Print("ucp signing enabled")
|
log.Print("ucp signing enabled")
|
||||||
}
|
}
|
||||||
mux.Handle("/ucp/v1/carts", cartUCP)
|
// StripPrefix is required because the sub-mux (CartHandler) registers
|
||||||
mux.Handle("/ucp/v1/carts/", cartUCP)
|
// 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
|
// Stateless promotion evaluation: POST a (possibly partial) eval context and
|
||||||
// get back the resulting totals plus the applied/pending effect breakdown,
|
// get back the resulting totals plus the applied/pending effect breakdown,
|
||||||
|
|||||||
@@ -194,8 +194,12 @@ func main() {
|
|||||||
checkoutUCP = ucp.WithSigning(checkoutUCP, signer)
|
checkoutUCP = ucp.WithSigning(checkoutUCP, signer)
|
||||||
log.Print("ucp signing enabled")
|
log.Print("ucp signing enabled")
|
||||||
}
|
}
|
||||||
mux.Handle("/ucp/v1/checkout-sessions", checkoutUCP)
|
// StripPrefix is required because the sub-mux (CheckoutHandler) registers
|
||||||
mux.Handle("/ucp/v1/checkout-sessions/", checkoutUCP)
|
// 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) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
grainCount, capacity := pool.LocalUsage()
|
grainCount, capacity := pool.LocalUsage()
|
||||||
|
|||||||
+6
-2
@@ -162,8 +162,12 @@ func main() {
|
|||||||
orderUCP = ucp.WithSigning(orderUCP, signer)
|
orderUCP = ucp.WithSigning(orderUCP, signer)
|
||||||
logger.Info("ucp signing enabled")
|
logger.Info("ucp signing enabled")
|
||||||
}
|
}
|
||||||
mux.Handle("/ucp/v1/orders", orderUCP)
|
// StripPrefix is required because the sub-mux (OrderHandler) registers
|
||||||
mux.Handle("/ucp/v1/orders/", orderUCP)
|
// 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).
|
// Saga / flow admin (backoffice editor).
|
||||||
mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
|
mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
|
||||||
|
|||||||
Reference in New Issue
Block a user