From ab52b0bfd9c0129f8d9cb3d9c193c27cc624d883 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 8 Jul 2026 15:19:31 +0400 Subject: [PATCH 1/2] test(smoke): live coverage for multi-route gate classes, per-offer hostname (P1b), and x402scan preflight (#697) --- .../sell_register_x402scan_preflight_test.go | 80 ++++++++++++++++ flows/flow-06-sell-setup.sh | 40 +++++++- flows/flow-07-sell-verify.sh | 96 +++++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 cmd/obol/sell_register_x402scan_preflight_test.go diff --git a/cmd/obol/sell_register_x402scan_preflight_test.go b/cmd/obol/sell_register_x402scan_preflight_test.go new file mode 100644 index 00000000..44a2b576 --- /dev/null +++ b/cmd/obol/sell_register_x402scan_preflight_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/ui" +) + +// preflightOpenAPI never fails the command — it only warns via u.Warn/Warnf, +// which write to UI's stderr writer. These tests call it directly against a +// plain httptest.Server (bypassing resolveX402scanOrigin's https/hostname +// gating, which is exercised separately in sell_register_x402scan_test.go). + +func TestPreflightOpenAPI_NoOperations(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"paths":{}}`)) + })) + defer srv.Close() + + var stderr bytes.Buffer + u := ui.NewForTest(&bytes.Buffer{}, &stderr) + preflightOpenAPI(context.Background(), u, srv.URL) + + if got := stderr.String(); !strings.Contains(got, "advertises no operations") { + t.Fatalf("expected no-operations warning, got: %q", got) + } +} + +func TestPreflightOpenAPI_MultiOfferSharedOrigin(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"paths":{ + "/services/foo/v1/chat/completions": {}, + "/services/bar/v1": {} + }}`)) + })) + defer srv.Close() + + var stderr bytes.Buffer + u := ui.NewForTest(&bytes.Buffer{}, &stderr) + preflightOpenAPI(context.Background(), u, srv.URL) + + if got := stderr.String(); !strings.Contains(got, "2 offers in one /openapi.json") { + t.Fatalf("expected multi-offer warning, got: %q", got) + } +} + +func TestPreflightOpenAPI_UnreachableOrNon200(t *testing.T) { + // Non-200: server up but returns an error status. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + var stderr bytes.Buffer + u := ui.NewForTest(&bytes.Buffer{}, &stderr) + preflightOpenAPI(context.Background(), u, srv.URL) + + if got := stderr.String(); !strings.Contains(got, "returned HTTP 500") { + t.Fatalf("expected HTTP-500 warning, got: %q", got) + } + + // Unreachable: close the server first so the connection is refused. + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) + origin := srv2.URL + srv2.Close() + + stderr.Reset() + preflightOpenAPI(context.Background(), u, origin) + + if got := stderr.String(); !strings.Contains(got, "could not fetch") { + t.Fatalf("expected unreachable-origin warning, got: %q", got) + } +} diff --git a/flows/flow-06-sell-setup.sh b/flows/flow-06-sell-setup.sh index e9ab454d..1d6d1b56 100755 --- a/flows/flow-06-sell-setup.sh +++ b/flows/flow-06-sell-setup.sh @@ -38,6 +38,10 @@ apply_flow_qwen_inference_offer() { "perRequest": "0.001" } }, + "routes": [ + {"path": "/health", "methods": ["GET"], "gate": "free"}, + {"path": "/v1/chat/completions", "methods": ["POST"], "gate": "paid"} + ], "path": "/services/flow-qwen", "registration": { "enabled": false @@ -227,6 +231,10 @@ if [ "$SELL_OFFER_TYPE" = "inference" ]; then poll_step_grep "Tunnel active for inference offer" "https://[a-z0-9-]+\\.trycloudflare\\.com" 12 5 \ "$OBOL" tunnel status else + # Route table: declared explicitly (instead of the implicit paid + # catch-all) so flow-07 can verify a free health route AND that an + # undeclared sibling path fails closed. --route path is relative to the + # offer's effective path (/services/flow-qwen). run_step_grep "sell http flow-qwen" \ "ServiceOffer.*created|ServiceOffer.*updated|agent will reconcile" \ "$OBOL" sell http flow-qwen \ @@ -236,7 +244,9 @@ else --per-request 0.001 \ --namespace llm \ --upstream "$SELL_UPSTREAM_SERVICE" \ - --port "$SELL_UPSTREAM_PORT" + --port "$SELL_UPSTREAM_PORT" \ + --route "path=/health,methods=GET,gate=free" \ + --route "path=/v1/chat/completions,methods=POST,gate=paid" # §1.4 UX: re-running sell http on the same SO shows "updated" not "created" step "sell http idempotent: re-run shows 'updated' not 'created'" @@ -244,7 +254,9 @@ else --wallet "$SELLER_WALLET" --chain "$CHAIN" \ --no-register \ --per-request 0.001 --namespace llm \ - --upstream "$SELL_UPSTREAM_SERVICE" --port "$SELL_UPSTREAM_PORT" 2>&1) || true + --upstream "$SELL_UPSTREAM_SERVICE" --port "$SELL_UPSTREAM_PORT" \ + --route "path=/health,methods=GET,gate=free" \ + --route "path=/v1/chat/completions,methods=POST,gate=paid" 2>&1) || true if echo "$rerun_out" | grep -q "ServiceOffer.*updated"; then pass "sell http idempotent: shows 'updated' on re-run" else @@ -303,4 +315,28 @@ else fail "HTTPRoute backend unexpected — ${route_backend:0:100}" fi +# P1b: per-offer hostname binding — give flow-qwen its own public origin. +# bindHostnameToOffer (cmd/obol/tunnel_domain.go) is a plain kubectl patch of +# spec.hostname with no cloudflared/DNS dependency, so it works even though +# this smoke stack only has the default quick-tunnel. tunnel.AddHostname +# (the DNS half of `tunnel hostname add`) requires a *permanent* tunnel and +# is expected to error in that case ("no permanent tunnel configured") — +# that failure is swallowed here; only the offer bind is asserted as a hard +# requirement, matching the graceful-degradation guidance for this env. +BOUND_HOSTNAME="flow-qwen-smoke.test.invalid" +step "obol tunnel hostname add --offer binds hostname to ServiceOffer (P1b)" +bind_out=$("$OBOL" tunnel hostname add "$BOUND_HOSTNAME" --offer llm/flow-qwen 2>&1) || true +if echo "$bind_out" | grep -q "Bound $BOUND_HOSTNAME to offer llm/flow-qwen"; then + pass "hostname bind: offer claimed $BOUND_HOSTNAME (tunnel-side DNS step may no-op without a permanent tunnel)" +else + fail "hostname bind to offer failed — ${bind_out:0:300}" +fi + +run_step_grep "ServiceOffer flow-qwen spec.hostname is $BOUND_HOSTNAME" "$BOUND_HOSTNAME" \ + "$OBOL" kubectl get serviceoffer flow-qwen -n llm -o "jsonpath={.spec.hostname}" + +poll_step_grep "Dedicated-origin HTTPRoute so-flow-qwen-host exists (controller reconcile)" \ + "so-flow-qwen-host" 12 5 \ + "$OBOL" kubectl get httproute so-flow-qwen-host -n llm + emit_metrics diff --git a/flows/flow-07-sell-verify.sh b/flows/flow-07-sell-verify.sh index 37ce54f5..1923dfef 100755 --- a/flows/flow-07-sell-verify.sh +++ b/flows/flow-07-sell-verify.sh @@ -163,6 +163,23 @@ else fail "No tunnel URL found — ${TUNNEL_OUTPUT:0:200}" fi +# #697: prove `obol sell register x402scan` CLI wiring is live. The smoke +# stack only has a quick-tunnel origin, which resolveX402scanOrigin +# (cmd/obol/sell_register_x402scan.go) deliberately rejects — assert the +# real rejection path fires (nonzero exit + the "quick-tunnel" message) +# rather than exercising the full remote-registration path, which needs a +# permanent hostname this env doesn't have. +if [ -n "$TUNNEL_URL" ]; then + step "sell register x402scan rejects quick-tunnel origin (CLI wiring, #697)" + x402scan_out=$("$OBOL" sell register x402scan --origin "$TUNNEL_URL" 2>&1) + x402scan_rc=$? + if [ "$x402scan_rc" -ne 0 ] && echo "$x402scan_out" | grep -q "quick-tunnel"; then + pass "sell register x402scan correctly rejected quick-tunnel origin" + else + fail "expected nonzero exit + 'quick-tunnel' message — rc=$x402scan_rc, out=${x402scan_out:0:200}" + fi +fi + # §1.6: Verify paths # Wait for x402-verifier to be ready — Kubernetes Reloader restarts pods when @@ -195,6 +212,85 @@ for i in $(seq 1 6); do sleep 5 done +# Multi-route gate classes: flow-06 declared an explicit route table +# (healthPath -> free, /v1/chat/completions -> paid) instead of the old +# implicit paid catch-all. Verify both halves of that route table are +# actually enforced by the live verifier. +step "free route (healthPath) reachable without payment" +free_code="" +for i in $(seq 1 3); do + free_code=$($CURL_OBOL -s --max-time 5 -o /dev/null -w '%{http_code}' \ + "$INGRESS_URL/services/flow-qwen/health" 2>&1) || true + [ "$free_code" = "200" ] && break + sleep 3 +done +if [ "$free_code" = "200" ]; then + pass "Free route /services/flow-qwen/health returned 200 without payment" +else + fail "Free route did not return 200 — got $free_code" +fi + +step "undeclared sibling path fails closed (not 200)" +undeclared_code="" +for i in $(seq 1 3); do + undeclared_code=$($CURL_OBOL -s --max-time 5 -o /dev/null -w '%{http_code}' \ + "$INGRESS_URL/services/flow-qwen/undeclared-smoke" 2>&1) || true + [ -n "$undeclared_code" ] && [ "$undeclared_code" != "000" ] && break + sleep 3 +done +if [ "$undeclared_code" != "200" ] && [ "$undeclared_code" != "000" ]; then + pass "Undeclared path fails closed — HTTP $undeclared_code (not 200)" +else + fail "Undeclared path did not fail closed — got $undeclared_code" +fi + +# P1b: per-offer hostname binding. flow-06 bound $BOUND_HOSTNAME to the +# flow-qwen offer's spec.hostname; the controller renders a dedicated-origin +# HTTPRoute (so-flow-qwen-host) that answers ONLY on that hostname, with +# routes rooted at "/" instead of "/services/flow-qwen" (monetizeapi +# ServiceOfferSpec.Hostname doc). That means this has to be verified with an +# explicit Host header directly against the Traefik Service — the shared +# $INGRESS_URL (obol.stack) does not match a per-offer hostname. +BOUND_HOSTNAME="flow-qwen-smoke.test.invalid" +so_bound_hostname=$("$OBOL" kubectl get serviceoffer flow-qwen -n llm \ + -o jsonpath='{.spec.hostname}' 2>&1) || true +if [ "$so_bound_hostname" = "$BOUND_HOSTNAME" ]; then + step "Dedicated origin ($BOUND_HOSTNAME) reachable via Host header (P1b)" + kill $(lsof -ti:18080) 2>/dev/null || true + "$OBOL" kubectl port-forward -n traefik svc/traefik 18080:80 &>/dev/null & + PF_HOST_PID=$! + for i in $(seq 1 10); do + if curl -s --max-time 2 -o /dev/null -H "Host: $BOUND_HOSTNAME" http://127.0.0.1:18080/ 2>/dev/null; then + break + fi + sleep 1 + done + # Root-relative path (no /services/flow-qwen prefix) — the dedicated + # origin's catch-all rewrites into the shared path-world internally. + host_code=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' \ + -H "Host: $BOUND_HOSTNAME" -X POST \ + "http://127.0.0.1:18080/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -d "{\"model\":\"$FLOW_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}" 2>&1) || true + cleanup_pid "$PF_HOST_PID" + if [ "$host_code" = "402" ]; then + pass "Dedicated origin reachable at its own hostname — HTTP 402 (not 404)" + else + fail "Dedicated origin not reachable via Host header — got $host_code (expected 402)" + fi + + step "Storefront catch-all did not absorb the bound hostname" + storefront_hostnames=$("$OBOL" kubectl get httproute -n traefik tunnel-storefront \ + -o jsonpath='{.spec.hostnames}' 2>&1) || true + if echo "$storefront_hostnames" | grep -q "$BOUND_HOSTNAME"; then + fail "Storefront HTTPRoute absorbed the offer-bound hostname — ${storefront_hostnames:0:200}" + else + pass "Storefront HTTPRoute does not list $BOUND_HOSTNAME" + fi +else + skip "P1b dedicated-origin checks — flow-06 did not bind $BOUND_HOSTNAME to flow-qwen (spec.hostname=$so_bound_hostname)" +fi + # Validate 402 JSON body has required x402 fields. # Retry: the first request after the verifier pod becomes Ready can return # an empty body / Bad Gateway from Traefik before the route is fully wired. From 59a2e7fb11674f5dc8003944f3057d16e3f90f52 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 8 Jul 2026 16:08:29 +0400 Subject: [PATCH 2/2] test(smoke): route-surface coverage on a dedicated offer (flow-21), not the shared flow-qwen fixture The earlier approach mutated the shared flow-qwen offer (added routes[] and bound a hostname). Binding spec.hostname moved flow-qwen off the storefront /services/flow-qwen path that flow-08/flow-09 depend on, breaking 'obol buy inference' ("seller catalog has no entry for /services/flow-qwen"). Revert the flow-06/07 mutations and instead cover multi-route gate classes (free/paid/undeclared-fail-closed) and per-offer hostname (P1b) on a self-contained throwaway offer, mirroring flow-19's pattern. The x402scan preflight Go unit test is retained. Surfaced by the rc1 spark1 smoke. Claude-Session: https://claude.ai/code/session_01VquWN9UMaSHH7MHGcG8bw1 --- flows/flow-06-sell-setup.sh | 40 +------- flows/flow-07-sell-verify.sh | 96 ------------------ flows/flow-21-route-surface.sh | 174 +++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 134 deletions(-) create mode 100755 flows/flow-21-route-surface.sh diff --git a/flows/flow-06-sell-setup.sh b/flows/flow-06-sell-setup.sh index 1d6d1b56..e9ab454d 100755 --- a/flows/flow-06-sell-setup.sh +++ b/flows/flow-06-sell-setup.sh @@ -38,10 +38,6 @@ apply_flow_qwen_inference_offer() { "perRequest": "0.001" } }, - "routes": [ - {"path": "/health", "methods": ["GET"], "gate": "free"}, - {"path": "/v1/chat/completions", "methods": ["POST"], "gate": "paid"} - ], "path": "/services/flow-qwen", "registration": { "enabled": false @@ -231,10 +227,6 @@ if [ "$SELL_OFFER_TYPE" = "inference" ]; then poll_step_grep "Tunnel active for inference offer" "https://[a-z0-9-]+\\.trycloudflare\\.com" 12 5 \ "$OBOL" tunnel status else - # Route table: declared explicitly (instead of the implicit paid - # catch-all) so flow-07 can verify a free health route AND that an - # undeclared sibling path fails closed. --route path is relative to the - # offer's effective path (/services/flow-qwen). run_step_grep "sell http flow-qwen" \ "ServiceOffer.*created|ServiceOffer.*updated|agent will reconcile" \ "$OBOL" sell http flow-qwen \ @@ -244,9 +236,7 @@ else --per-request 0.001 \ --namespace llm \ --upstream "$SELL_UPSTREAM_SERVICE" \ - --port "$SELL_UPSTREAM_PORT" \ - --route "path=/health,methods=GET,gate=free" \ - --route "path=/v1/chat/completions,methods=POST,gate=paid" + --port "$SELL_UPSTREAM_PORT" # §1.4 UX: re-running sell http on the same SO shows "updated" not "created" step "sell http idempotent: re-run shows 'updated' not 'created'" @@ -254,9 +244,7 @@ else --wallet "$SELLER_WALLET" --chain "$CHAIN" \ --no-register \ --per-request 0.001 --namespace llm \ - --upstream "$SELL_UPSTREAM_SERVICE" --port "$SELL_UPSTREAM_PORT" \ - --route "path=/health,methods=GET,gate=free" \ - --route "path=/v1/chat/completions,methods=POST,gate=paid" 2>&1) || true + --upstream "$SELL_UPSTREAM_SERVICE" --port "$SELL_UPSTREAM_PORT" 2>&1) || true if echo "$rerun_out" | grep -q "ServiceOffer.*updated"; then pass "sell http idempotent: shows 'updated' on re-run" else @@ -315,28 +303,4 @@ else fail "HTTPRoute backend unexpected — ${route_backend:0:100}" fi -# P1b: per-offer hostname binding — give flow-qwen its own public origin. -# bindHostnameToOffer (cmd/obol/tunnel_domain.go) is a plain kubectl patch of -# spec.hostname with no cloudflared/DNS dependency, so it works even though -# this smoke stack only has the default quick-tunnel. tunnel.AddHostname -# (the DNS half of `tunnel hostname add`) requires a *permanent* tunnel and -# is expected to error in that case ("no permanent tunnel configured") — -# that failure is swallowed here; only the offer bind is asserted as a hard -# requirement, matching the graceful-degradation guidance for this env. -BOUND_HOSTNAME="flow-qwen-smoke.test.invalid" -step "obol tunnel hostname add --offer binds hostname to ServiceOffer (P1b)" -bind_out=$("$OBOL" tunnel hostname add "$BOUND_HOSTNAME" --offer llm/flow-qwen 2>&1) || true -if echo "$bind_out" | grep -q "Bound $BOUND_HOSTNAME to offer llm/flow-qwen"; then - pass "hostname bind: offer claimed $BOUND_HOSTNAME (tunnel-side DNS step may no-op without a permanent tunnel)" -else - fail "hostname bind to offer failed — ${bind_out:0:300}" -fi - -run_step_grep "ServiceOffer flow-qwen spec.hostname is $BOUND_HOSTNAME" "$BOUND_HOSTNAME" \ - "$OBOL" kubectl get serviceoffer flow-qwen -n llm -o "jsonpath={.spec.hostname}" - -poll_step_grep "Dedicated-origin HTTPRoute so-flow-qwen-host exists (controller reconcile)" \ - "so-flow-qwen-host" 12 5 \ - "$OBOL" kubectl get httproute so-flow-qwen-host -n llm - emit_metrics diff --git a/flows/flow-07-sell-verify.sh b/flows/flow-07-sell-verify.sh index 1923dfef..37ce54f5 100755 --- a/flows/flow-07-sell-verify.sh +++ b/flows/flow-07-sell-verify.sh @@ -163,23 +163,6 @@ else fail "No tunnel URL found — ${TUNNEL_OUTPUT:0:200}" fi -# #697: prove `obol sell register x402scan` CLI wiring is live. The smoke -# stack only has a quick-tunnel origin, which resolveX402scanOrigin -# (cmd/obol/sell_register_x402scan.go) deliberately rejects — assert the -# real rejection path fires (nonzero exit + the "quick-tunnel" message) -# rather than exercising the full remote-registration path, which needs a -# permanent hostname this env doesn't have. -if [ -n "$TUNNEL_URL" ]; then - step "sell register x402scan rejects quick-tunnel origin (CLI wiring, #697)" - x402scan_out=$("$OBOL" sell register x402scan --origin "$TUNNEL_URL" 2>&1) - x402scan_rc=$? - if [ "$x402scan_rc" -ne 0 ] && echo "$x402scan_out" | grep -q "quick-tunnel"; then - pass "sell register x402scan correctly rejected quick-tunnel origin" - else - fail "expected nonzero exit + 'quick-tunnel' message — rc=$x402scan_rc, out=${x402scan_out:0:200}" - fi -fi - # §1.6: Verify paths # Wait for x402-verifier to be ready — Kubernetes Reloader restarts pods when @@ -212,85 +195,6 @@ for i in $(seq 1 6); do sleep 5 done -# Multi-route gate classes: flow-06 declared an explicit route table -# (healthPath -> free, /v1/chat/completions -> paid) instead of the old -# implicit paid catch-all. Verify both halves of that route table are -# actually enforced by the live verifier. -step "free route (healthPath) reachable without payment" -free_code="" -for i in $(seq 1 3); do - free_code=$($CURL_OBOL -s --max-time 5 -o /dev/null -w '%{http_code}' \ - "$INGRESS_URL/services/flow-qwen/health" 2>&1) || true - [ "$free_code" = "200" ] && break - sleep 3 -done -if [ "$free_code" = "200" ]; then - pass "Free route /services/flow-qwen/health returned 200 without payment" -else - fail "Free route did not return 200 — got $free_code" -fi - -step "undeclared sibling path fails closed (not 200)" -undeclared_code="" -for i in $(seq 1 3); do - undeclared_code=$($CURL_OBOL -s --max-time 5 -o /dev/null -w '%{http_code}' \ - "$INGRESS_URL/services/flow-qwen/undeclared-smoke" 2>&1) || true - [ -n "$undeclared_code" ] && [ "$undeclared_code" != "000" ] && break - sleep 3 -done -if [ "$undeclared_code" != "200" ] && [ "$undeclared_code" != "000" ]; then - pass "Undeclared path fails closed — HTTP $undeclared_code (not 200)" -else - fail "Undeclared path did not fail closed — got $undeclared_code" -fi - -# P1b: per-offer hostname binding. flow-06 bound $BOUND_HOSTNAME to the -# flow-qwen offer's spec.hostname; the controller renders a dedicated-origin -# HTTPRoute (so-flow-qwen-host) that answers ONLY on that hostname, with -# routes rooted at "/" instead of "/services/flow-qwen" (monetizeapi -# ServiceOfferSpec.Hostname doc). That means this has to be verified with an -# explicit Host header directly against the Traefik Service — the shared -# $INGRESS_URL (obol.stack) does not match a per-offer hostname. -BOUND_HOSTNAME="flow-qwen-smoke.test.invalid" -so_bound_hostname=$("$OBOL" kubectl get serviceoffer flow-qwen -n llm \ - -o jsonpath='{.spec.hostname}' 2>&1) || true -if [ "$so_bound_hostname" = "$BOUND_HOSTNAME" ]; then - step "Dedicated origin ($BOUND_HOSTNAME) reachable via Host header (P1b)" - kill $(lsof -ti:18080) 2>/dev/null || true - "$OBOL" kubectl port-forward -n traefik svc/traefik 18080:80 &>/dev/null & - PF_HOST_PID=$! - for i in $(seq 1 10); do - if curl -s --max-time 2 -o /dev/null -H "Host: $BOUND_HOSTNAME" http://127.0.0.1:18080/ 2>/dev/null; then - break - fi - sleep 1 - done - # Root-relative path (no /services/flow-qwen prefix) — the dedicated - # origin's catch-all rewrites into the shared path-world internally. - host_code=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' \ - -H "Host: $BOUND_HOSTNAME" -X POST \ - "http://127.0.0.1:18080/v1/chat/completions" \ - -H "Content-Type: application/json" \ - -d "{\"model\":\"$FLOW_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}" 2>&1) || true - cleanup_pid "$PF_HOST_PID" - if [ "$host_code" = "402" ]; then - pass "Dedicated origin reachable at its own hostname — HTTP 402 (not 404)" - else - fail "Dedicated origin not reachable via Host header — got $host_code (expected 402)" - fi - - step "Storefront catch-all did not absorb the bound hostname" - storefront_hostnames=$("$OBOL" kubectl get httproute -n traefik tunnel-storefront \ - -o jsonpath='{.spec.hostnames}' 2>&1) || true - if echo "$storefront_hostnames" | grep -q "$BOUND_HOSTNAME"; then - fail "Storefront HTTPRoute absorbed the offer-bound hostname — ${storefront_hostnames:0:200}" - else - pass "Storefront HTTPRoute does not list $BOUND_HOSTNAME" - fi -else - skip "P1b dedicated-origin checks — flow-06 did not bind $BOUND_HOSTNAME to flow-qwen (spec.hostname=$so_bound_hostname)" -fi - # Validate 402 JSON body has required x402 fields. # Retry: the first request after the verifier pod becomes Ready can return # an empty body / Bad Gateway from Traefik before the route is fully wired. diff --git a/flows/flow-21-route-surface.sh b/flows/flow-21-route-surface.sh new file mode 100755 index 00000000..595ad71c --- /dev/null +++ b/flows/flow-21-route-surface.sh @@ -0,0 +1,174 @@ +#!/bin/bash +# Flow 21: Route Surface — multi-route gate classes + per-offer hostname (P1b). +# #717 (spec.routes[] gate classes) / #718 (P1b hostname fail-safe). +# +# A single ServiceOffer declares an explicit route table with mixed gate +# classes (NO implicit paid catch-all), so the live verifier's per-route +# enforcement can be observed end-to-end: +# +# free /health/readiness -> 200 without payment (gate=free proxied) +# paid /v1/chat/completions -> 402 without payment (gate=paid, gate +# answers before ever touching upstream) +# undeclared /nope -> fails closed (NOT 200 — the live +# HandleProxy path 404s an unmatched path) +# +# Then the offer is given its own dedicated origin via `obol tunnel hostname +# add --offer` (bindHostnameToOffer patches spec.hostname; the controller +# renders an so--host HTTPRoute) and the paid route is re-checked via a +# Host: header against the in-cluster Traefik Service — no external DNS. The +# storefront catch-all must NOT list the bound host. The hostname block is +# SKIP-tolerant: the dedicated-origin render needs no tunnel, but if it does +# not materialize in this environment we skip rather than fail, keeping the +# gate-class coverage (the primary #717 surface) hard-asserted. +# +# Self-contained: its own throwaway offer on the always-present litellm +# Service (never mutates the shared flow-qwen fixture that flow-08/09 rely +# on). gate=free/paid need no facilitator/anvil — only the baseline stack. +source "$(dirname "$0")/lib.sh" + +require_tool python3 +require_tool curl + +refresh_obol_ingress_env +INGRESS_URL="${OBOL_INGRESS_URL%/}" + +OFFER_NAME="flow21-routes" +OFFER_NS="llm" +FREE_PATH="/health/readiness" +PAID_PATH="/v1/chat/completions" +BASE="$INGRESS_URL/services/$OFFER_NAME" +BOUND_HOST="flow21-routes.smoke.test.invalid" + +PF_PID="" +cleanup() { + [ -n "$PF_PID" ] && cleanup_pid "$PF_PID" + "$OBOL" sell delete "$OFFER_NAME" --namespace "$OFFER_NS" --force >/dev/null 2>&1 || true +} +trap cleanup EXIT + +# ═════════════════════════════════════════════════════════════════ +# §1: Declare a multi-route offer (free + paid, no catch-all) +# ═════════════════════════════════════════════════════════════════ +"$OBOL" sell delete "$OFFER_NAME" --namespace "$OFFER_NS" --force >/dev/null 2>&1 || true +sleep 1 + +run_step_grep "sell http $OFFER_NAME declares free+paid route table" \ + "ServiceOffer.*created|ServiceOffer.*updated|agent will reconcile" \ + "$OBOL" sell http "$OFFER_NAME" \ + --pay-to "$SELLER_WALLET" \ + --chain "$CHAIN" \ + --price 0.001 \ + --namespace "$OFFER_NS" \ + --upstream litellm \ + --port 4000 \ + --health-path "$FREE_PATH" \ + --no-register \ + --route "path=$FREE_PATH,gate=free" \ + --route "path=$PAID_PATH,gate=paid" + +poll_step_grep "ServiceOffer $OFFER_NAME Ready=True" "True" 48 5 \ + "$OBOL" kubectl get serviceoffers.obol.org "$OFFER_NAME" -n "$OFFER_NS" \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' + +# ═════════════════════════════════════════════════════════════════ +# §2: gate=free — 200 without payment +# ═════════════════════════════════════════════════════════════════ +step "Free route ($FREE_PATH) returns 200 without payment" +free_code="000" +for _ in $(seq 1 6); do + free_code=$($CURL_OBOL -s -o /dev/null -w '%{http_code}' --max-time 8 \ + "$BASE$FREE_PATH" 2>/dev/null || echo "000") + [ "$free_code" = "200" ] && break + sleep 5 +done +if [ "$free_code" = "200" ]; then + pass "Free route -> 200 (no payment required)" +else + fail "Free route expected 200, got $free_code" +fi + +# ═════════════════════════════════════════════════════════════════ +# §3: gate=paid — 402 without payment (gate answers before upstream) +# ═════════════════════════════════════════════════════════════════ +step "Paid route ($PAID_PATH) returns 402 without payment" +paid_code="000" +for _ in $(seq 1 6); do + paid_code=$($CURL_OBOL -s -o /dev/null -w '%{http_code}' --max-time 8 \ + "$BASE$PAID_PATH" 2>/dev/null || echo "000") + [ "$paid_code" = "402" ] && break + sleep 5 +done +if [ "$paid_code" = "402" ]; then + pass "Paid route -> 402 (payment required)" +else + fail "Paid route expected 402, got $paid_code" +fi + +# ═════════════════════════════════════════════════════════════════ +# §4: undeclared sibling — fails closed (NOT 200) +# ═════════════════════════════════════════════════════════════════ +step "Undeclared sibling path fails closed (not 200)" +undeclared_code=$($CURL_OBOL -s -o /dev/null -w '%{http_code}' --max-time 8 \ + "$BASE/nope-undeclared" 2>/dev/null || echo "000") +if [ "$undeclared_code" != "200" ] && [ "$undeclared_code" != "000" ]; then + pass "Undeclared path fails closed — HTTP $undeclared_code (not 200)" +else + fail "Undeclared path did not fail closed — got $undeclared_code" +fi + +# ═════════════════════════════════════════════════════════════════ +# §5: per-offer hostname (P1b) — dedicated origin via Host header. +# SKIP-tolerant: the render needs no external DNS, but if the dedicated +# HTTPRoute does not appear we skip rather than fail. +# ═════════════════════════════════════════════════════════════════ +step "Bind a dedicated hostname to the offer (obol tunnel hostname add --offer)" +bind_out=$("$OBOL" tunnel hostname add "$BOUND_HOST" --offer "$OFFER_NS/$OFFER_NAME" 2>&1) || true +so_host=$("$OBOL" kubectl get serviceoffer "$OFFER_NAME" -n "$OFFER_NS" \ + -o jsonpath='{.spec.hostname}' 2>/dev/null || true) +if [ "$so_host" = "$BOUND_HOST" ]; then + pass "spec.hostname bound to $BOUND_HOST (tunnel DNS half may no-op without a permanent tunnel)" + + # Wait for the controller to render the dedicated-origin HTTPRoute. + host_route="" + for _ in $(seq 1 12); do + host_route=$("$OBOL" kubectl get httproute "so-$OFFER_NAME-host" -n "$OFFER_NS" \ + -o jsonpath='{.metadata.name}' 2>/dev/null || true) + [ -n "$host_route" ] && break + sleep 5 + done + + if [ -n "$host_route" ]; then + step "Dedicated origin reachable at its own hostname via Host header (P1b)" + lp="${FLOW21_TRAEFIK_PORT:-$(pick_free_port)}" + "$OBOL" kubectl port-forward -n traefik svc/traefik "${lp}:80" &>/dev/null & + PF_PID=$! + for _ in $(seq 1 10); do + curl -s -o /dev/null --max-time 2 -H "Host: $BOUND_HOST" "http://127.0.0.1:${lp}/" 2>/dev/null && break + kill -0 "$PF_PID" 2>/dev/null || break + sleep 1 + done + host_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \ + -H "Host: $BOUND_HOST" "http://127.0.0.1:${lp}${PAID_PATH}" 2>/dev/null || echo "000") + cleanup_pid "$PF_PID"; PF_PID="" + if [ "$host_code" = "402" ]; then + pass "Dedicated origin serves the paid route at its own hostname — HTTP 402 (not 404)" + else + fail "Dedicated origin not reachable via Host header — got $host_code (expected 402)" + fi + + step "Storefront catch-all did not absorb the bound hostname (P1b fail-safe)" + sf_hosts=$("$OBOL" kubectl get httproute tunnel-storefront -n traefik \ + -o jsonpath='{.spec.hostnames}' 2>/dev/null || true) + if echo "$sf_hosts" | grep -q "$BOUND_HOST"; then + fail "Storefront HTTPRoute absorbed the offer-bound hostname — ${sf_hosts:0:200}" + else + pass "Storefront HTTPRoute does not list $BOUND_HOST" + fi + else + skip "Dedicated-origin HTTPRoute so-$OFFER_NAME-host did not render in this env — hostname routing not asserted" + fi +else + skip "hostname bind did not set spec.hostname (got '$so_host') — bind_out: ${bind_out:0:200}" +fi + +emit_metrics