diff --git a/artifacts/api-server/src/routes/demo.ts b/artifacts/api-server/src/routes/demo.ts index c53dcc5..7595353 100644 --- a/artifacts/api-server/src/routes/demo.ts +++ b/artifacts/api-server/src/routes/demo.ts @@ -43,6 +43,7 @@ router.get("/demo", async (req: Request, res: Response) => { if (!allowed) { const secsUntilReset = Math.ceil((resetAt - Date.now()) / 1000); logger.warn("demo rate limited", { ip, retry_after_s: secsUntilReset }); + res.setHeader("Retry-After", String(secsUntilReset)); res.status(429).json({ error: `Rate limit exceeded. Try again in ${secsUntilReset}s (5 requests per hour per IP).`, }); diff --git a/artifacts/api-server/src/routes/testkit.ts b/artifacts/api-server/src/routes/testkit.ts index 677e1be..04847a0 100644 --- a/artifacts/api-server/src/routes/testkit.ts +++ b/artifacts/api-server/src/routes/testkit.ts @@ -29,6 +29,10 @@ const router = Router(); * Guarded on stubMode=true; polls until state=provisioning|ready (20 s timeout). * - T24 ADDED: costLedger completeness after job completion — 8 fields, honest-accounting * invariant (actualAmountSats ≤ workAmountSats), refundState enum check. + * - T37 ADDED: GET /api/healthz extended — verifies uptime_s and jobs_total fields (observability). + * - T38 ADDED: GET /api/metrics — full snapshot field verification, zero prior coverage. + * - T39 ADDED: GET /api/estimate — cost-preview endpoint, estimatedSats + model + tokens. + * - T40 ADDED: Demo 429 Retry-After header — RFC 7231 compliance on rate-limited response. */ router.get("/testkit", (req: Request, res: Response) => { const proto = @@ -1092,27 +1096,117 @@ NODESCRIPT fi fi +# --------------------------------------------------------------------------- +# Test 37 — Healthz extended fields (uptime_s, jobs_total) +# T1 checks status=ok; this test verifies the observability fields are present +# and have sensible types (uptime_s >= 0, jobs_total >= 0). +# --------------------------------------------------------------------------- +sep "Test 37 — Healthz extended fields (uptime_s, jobs_total)" +T37_RES=\$(curl -s -w "\\n%{http_code}" "\$BASE/api/healthz") +T37_BODY=\$(body_of "\$T37_RES"); T37_CODE=\$(code_of "\$T37_RES") +T37_UPTIME=\$(echo "\$T37_BODY" | jq '.uptime_s' 2>/dev/null || echo "") +T37_JOBS=\$(echo "\$T37_BODY" | jq '.jobs_total' 2>/dev/null || echo "") +if [[ "\$T37_CODE" == "200" ]] && \\ + [[ "\$T37_UPTIME" =~ ^[0-9]+$ ]] && \\ + [[ "\$T37_JOBS" =~ ^[0-9]+$ ]]; then + note PASS "uptime_s=\$T37_UPTIME jobs_total=\$T37_JOBS" + PASS=\$((PASS+1)) +else + note FAIL "code=\$T37_CODE uptime_s='\$T37_UPTIME' jobs_total='\$T37_JOBS' body=\$T37_BODY" + FAIL=\$((FAIL+1)) +fi + +# --------------------------------------------------------------------------- +# Test 38 — Metrics endpoint (GET /api/metrics) +# Verifies all top-level metric sections are present and well-typed: +# uptime_s (int), jobs.total (int), jobs.by_state (object), +# invoices.conversion_rate (number|null), earnings.total_sats (int), +# latency (object). +# --------------------------------------------------------------------------- +sep "Test 38 — Metrics endpoint" +T38_RES=\$(curl -s -w "\\n%{http_code}" "\$BASE/api/metrics") +T38_BODY=\$(body_of "\$T38_RES"); T38_CODE=\$(code_of "\$T38_RES") +T38_UPTIME=\$(echo "\$T38_BODY" | jq '.uptime_s' 2>/dev/null || echo "") +T38_JOBS_TOTAL=\$(echo "\$T38_BODY" | jq '.jobs.total' 2>/dev/null || echo "") +T38_BY_STATE=\$(echo "\$T38_BODY" | jq '.jobs.by_state | type' 2>/dev/null || echo "") +T38_CONV_RATE=\$(echo "\$T38_BODY" | jq '.invoices.conversion_rate' 2>/dev/null || echo "") +T38_SATS=\$(echo "\$T38_BODY" | jq '.earnings.total_sats' 2>/dev/null || echo "") +T38_LATENCY=\$(echo "\$T38_BODY" | jq '.latency | type' 2>/dev/null || echo "") +if [[ "\$T38_CODE" == "200" ]] && \\ + [[ "\$T38_UPTIME" =~ ^[0-9]+$ ]] && \\ + [[ "\$T38_JOBS_TOTAL" =~ ^[0-9]+$ ]] && \\ + [[ "\$T38_BY_STATE" == '"object"' ]] && \\ + [[ "\$T38_CONV_RATE" != "" ]] && \\ + [[ "\$T38_SATS" =~ ^[0-9]+$ ]] && \\ + [[ "\$T38_LATENCY" == '"object"' ]]; then + note PASS "uptime_s=\$T38_UPTIME jobs_total=\$T38_JOBS_TOTAL sats_earned=\$T38_SATS" + PASS=\$((PASS+1)) +else + note FAIL "code=\$T38_CODE body=\$T38_BODY" + FAIL=\$((FAIL+1)) +fi + +# --------------------------------------------------------------------------- +# Test 39 — Estimate endpoint (GET /api/estimate?request=hello) +# Verifies the cost-preview endpoint returns estimatedSats, model, and +# token estimates. No payment required, no job created. +# --------------------------------------------------------------------------- +sep "Test 39 — Estimate endpoint" +T39_RES=\$(curl -s -w "\\n%{http_code}" "\$BASE/api/estimate?request=hello") +T39_BODY=\$(body_of "\$T39_RES"); T39_CODE=\$(code_of "\$T39_RES") +T39_SATS=\$(echo "\$T39_BODY" | jq '.estimatedSats' 2>/dev/null || echo "") +T39_MODEL=\$(echo "\$T39_BODY" | jq -r '.tokenEstimate.model' 2>/dev/null || echo "") +T39_INPUT=\$(echo "\$T39_BODY" | jq '.tokenEstimate.inputTokens' 2>/dev/null || echo "") +T39_OUTPUT=\$(echo "\$T39_BODY" | jq '.tokenEstimate.outputTokens' 2>/dev/null || echo "") +T39_IDENTITY=\$(echo "\$T39_BODY" | jq '.identity | type' 2>/dev/null || echo "") +T39_POOL=\$(echo "\$T39_BODY" | jq '.pool | type' 2>/dev/null || echo "") +if [[ "\$T39_CODE" == "200" ]] && \\ + [[ "\$T39_SATS" =~ ^[0-9]+$ ]] && \\ + [[ -n "\$T39_MODEL" && "\$T39_MODEL" != "null" ]] && \\ + [[ "\$T39_INPUT" =~ ^[0-9]+$ ]] && \\ + [[ "\$T39_OUTPUT" =~ ^[0-9]+$ ]] && \\ + [[ "\$T39_IDENTITY" == '"object"' ]] && \\ + [[ "\$T39_POOL" == '"object"' ]]; then + note PASS "estimatedSats=\$T39_SATS model=\$T39_MODEL input=\$T39_INPUT output=\$T39_OUTPUT" + PASS=\$((PASS+1)) +else + note FAIL "code=\$T39_CODE body=\$T39_BODY" + FAIL=\$((FAIL+1)) +fi + +# --------------------------------------------------------------------------- +# Test 40 — Demo 429 includes Retry-After header +# By this point the rate-limit window is exhausted. Verify the 429 response +# includes a numeric Retry-After header (RFC 7231 §7.1.3). +# --------------------------------------------------------------------------- +sep "Test 40 — Demo 429 includes Retry-After header" +T40_OUT=\$(curl -si "\$BASE/api/demo?request=retry+after+probe" 2>/dev/null || true) +T40_CODE=\$(echo "\$T40_OUT" | grep -i "^HTTP/" | tail -1 | awk '{print \$2}') +T40_RETRY=\$(echo "\$T40_OUT" | grep -i "^Retry-After:" | head -1 | tr -d '\\r' | awk '{print \$2}') +if [[ "\$T40_CODE" == "429" ]] && [[ "\$T40_RETRY" =~ ^[0-9]+$ ]]; then + note PASS "HTTP 429 with Retry-After=\$T40_RETRY" + PASS=\$((PASS+1)) +else + note FAIL "code=\$T40_CODE Retry-After='\$T40_RETRY'" + FAIL=\$((FAIL+1)) +fi + # =========================================================================== # FUTURE STUBS — placeholders for upcoming tasks (do not affect PASS/FAIL) # =========================================================================== # These are bash comments only. They document planned tests so future tasks # can implement them with the correct numbering context. # -# FUTURE T37: GET /api/estimate returns cost preview -# GET \$BASE/api/estimate?request= -# Assert HTTP 200, estimatedSats is a positive integer -# Assert model, inputTokens, outputTokens are present -# -# FUTURE T38: Anonymous job always hits Lightning gate +# FUTURE T41: Anonymous job always hits Lightning gate # Create anonymous job, poll to awaiting_work_payment # Assert response.free_tier is absent or false in all poll responses # -# FUTURE T39: Nostr-identified trusted identity → free response +# FUTURE T42: Nostr-identified trusted identity → free response # Requires identity with trust_score >= 50 (trusted tier) and daily budget not exhausted # Submit request with identity token # Assert HTTP 200, response.free_tier == true, no invoice created # -# FUTURE T40: Timmy initiates a zap +# FUTURE T43: Timmy initiates a zap # POST to /api/identity/me/tip (or similar) # Assert Timmy initiates a Lightning outbound payment to caller's LNURL