From 5cca46284388f529cd72daf21213feaa8e85b495 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 1 May 2026 20:12:49 -0700 Subject: [PATCH] harness(phase-0): sudo-free Host-header path + chat_history + envelope replays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that bring the local harness from "covers what staging covers minus the SaaS topology" to "exercises every surface we shipped this session against the prod-shape Dockerfile.tenant image." 1. Drop the /etc/hosts requirement. Replays previously needed `127.0.0.1 harness-tenant.localhost` in /etc/hosts to resolve the cf-proxy. That gated the harness behind a sudo step on every fresh dev box and CI runner. The cf-proxy nginx already routes by Host header (matches production CF tunnel: URL is public, Host carries tenant identity), so the no-sudo path is to target loopback :8080 with `Host: harness-tenant.localhost` set as a header. New `tests/harness/_curl.sh` centralises this — curl_anon / curl_admin / curl_workspace / psql_exec wrappers all set the Host + auth headers automatically. seed.sh, peer-discovery-404.sh, buildinfo-stale-image.sh updated to source it. Legacy /etc/hosts users still work via env-var override. 2. Fix the seed.sh FK regression that blocked DB-side replays. POST /workspaces ignores any `id` in the request body and generates one server-side. seed.sh was minting client-side UUIDs that never reached the workspaces table, so any replay that INSERTed into activity_logs (FK-constrained on workspace_id) failed with the workspace-not-found error. Capture the returned id from the response instead. 3. Two new replays cover the surfaces shipped this session. chat-history.sh — exercises the full SaaS-shape wire that PR #2472 (peer_id filter), #2474 (chat_history client tool), and #2476 (before_ts paging) ride on. 8 phases / 16 assertions: peer_id filter, limit cap, before_ts paging, OR-clause covering both source_id and target_id, malformed peer_id 400, malformed before_ts 400, URL-encoded SQLi-shape rejection. Verified PASS against the live harness. channel-envelope-trust-boundary.sh — exercises PR #2471 + #2481 by importing from `molecule_runtime.*` (the wheel-rewritten path) so it catches "wheel build dropped a fix that unit tests still pass." 5 phases / 11 assertions: malicious peer_id scrubbed from envelope, agent_card_url omitted on validation failure, XML-injection bytes scrubbed, valid UUID preserved, _agent_card_url_for direct gate. Verified PASS against published wheel 0.1.79. run-all-replays.sh auto-discovers — no registration needed. Full lifecycle (boot → seed → 4 replays → teardown) runs clean. Roadmap section updated to reflect Phase 1 (this PR) → Phase 2 (multi-tenant + CI gate) → Phase 3 (real CP) → Phase 4 (Miniflare + LocalStack + traffic replay). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + tests/harness/.gitignore | 2 + tests/harness/README.md | 39 ++-- tests/harness/_curl.sh | 82 ++++++++ .../harness/replays/buildinfo-stale-image.sh | 6 +- .../channel-envelope-trust-boundary.sh | 182 ++++++++++++++++++ tests/harness/replays/chat-history.sh | 175 +++++++++++++++++ tests/harness/replays/peer-discovery-404.sh | 10 +- tests/harness/seed.sh | 55 +++--- tests/harness/up.sh | 21 +- 10 files changed, 513 insertions(+), 60 deletions(-) create mode 100644 tests/harness/.gitignore create mode 100644 tests/harness/_curl.sh create mode 100755 tests/harness/replays/channel-envelope-trust-boundary.sh create mode 100755 tests/harness/replays/chat-history.sh diff --git a/.gitignore b/.gitignore index 05da25ee..3b6e7451 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,4 @@ backups/ *-temp.txt /test-pmm-*.txt /tick-reflections-*.md +tests/harness/cp-stub/cp-stub diff --git a/tests/harness/.gitignore b/tests/harness/.gitignore new file mode 100644 index 00000000..193e2b48 --- /dev/null +++ b/tests/harness/.gitignore @@ -0,0 +1,2 @@ +# Harness ephemeral state. Re-generated by ./seed.sh on every boot. +.seed.env diff --git a/tests/harness/README.md b/tests/harness/README.md index 1306d8ae..bf0ad93e 100644 --- a/tests/harness/README.md +++ b/tests/harness/README.md @@ -1,12 +1,20 @@ # Production-shape local harness The harness brings up the SaaS tenant topology on localhost using the -same `Dockerfile.tenant` image that ships to production. Tests run -against `http://harness-tenant.localhost:8080` and exercise the -SAME code path a real tenant takes — including TenantGuard middleware, +same `Dockerfile.tenant` image that ships to production. Tests target +the cf-proxy on `http://localhost:8080` and pass the tenant identity +via a `Host: harness-tenant.localhost` header — exactly the way +production CF tunnel routes by Host header. The cf-proxy nginx then +rewrites headers and proxies to the tenant container, exercising the +SAME code path a real tenant takes including TenantGuard middleware, the `/cp/*` reverse proxy, the canvas reverse proxy, and a Cloudflare-tunnel-shape header rewrite layer. +`tests/harness/_curl.sh` is the helper sourced by every replay — +provides `curl_anon`, `curl_admin`, `curl_workspace`, and `psql_exec` +wrappers that set the right Host + auth headers automatically. New +replays should source it rather than rolling their own curl. + ## Why this exists Local `go run ./cmd/server` skips: @@ -53,15 +61,18 @@ KEEP_UP=1 ./run-all-replays.sh # leave harness up for debugging REBUILD=1 ./run-all-replays.sh # rebuild images before booting ``` -First-time setup needs an `/etc/hosts` entry so `harness-tenant.localhost` -resolves to the local cf-proxy: +No `/etc/hosts` edit required — replays use the cf-proxy's loopback +port and pass `Host: harness-tenant.localhost` as a header (`_curl.sh` +handles this automatically). This matches how production CF tunnel +routes: the URL is the public CF endpoint, the Host header carries the +per-tenant identity. Quick check: ```bash -echo "127.0.0.1 harness-tenant.localhost" | sudo tee -a /etc/hosts +curl -H "Host: harness-tenant.localhost" http://localhost:8080/health ``` -(macOS resolves `*.localhost` automatically in some setups; Linux -typically does not.) +(If you have a legacy `/etc/hosts` entry from older docs, it still +works — `BASE` and `TENANT_HOST` both honor env-var overrides.) ## Replay scripts @@ -74,6 +85,8 @@ green" — the script becomes the regression gate that closes that gap. |--------|--------|----------------| | `peer-discovery-404.sh` | #2397 | tool_list_peers surfaces the actual reason instead of "may be isolated" | | `buildinfo-stale-image.sh` | #2395 | GIT_SHA reaches the binary; verify-step comparison logic works | +| `chat-history.sh` | #2472 + #2474 + #2476 | `peer_id` filter (incl. OR over source/target) + `before_ts` paging + UUID/RFC3339 trust boundary on the activity route | +| `channel-envelope-trust-boundary.sh` | #2471 + #2481 | published wheel scrubs malformed `peer_id` from the channel envelope and from `agent_card_url` (path-traversal + XML-attr injection) | To add a new replay: 1. Drop a script under `replays/` named after the issue. @@ -111,9 +124,7 @@ its mandate of "exercise the tenant binary in production-shape topology." ## Roadmap -- **Phase 1 (shipped):** harness + cp-stub + cf-proxy + 2 replays + `run-all-replays.sh` runner. -- **Phase 2:** convert `tests/e2e/test_api.sh` to run against the - harness instead of localhost. Make harness-based E2E a required CI - check (a workflow that invokes `run-all-replays.sh` on every PR). -- **Phase 3:** config-coherence lint that diffs harness env list - against production CP's env list, fails CI on drift. +- **Phase 1 (shipped):** harness + cp-stub + cf-proxy + 4 replays + `run-all-replays.sh` runner. No-sudo `Host`-header path via `_curl.sh`. Per-replay psql seeding for tests that need DB-side fixtures. +- **Phase 2 (in flight):** multi-tenant — second `tenant-beta` service in compose, second Postgres database, replays for cross-tenant A2A + TenantGuard isolation. Convert `tests/e2e/test_api.sh` to target the harness instead of localhost. Make harness-based E2E a required CI check (a workflow that invokes `run-all-replays.sh` on every PR via the self-hosted Mac runner). +- **Phase 3:** replace `cp-stub/` with the real `molecule-controlplane` Docker build. Add a config-coherence lint that diffs harness env list against production CP's env list and fails CI on drift. +- **Phase 4 (long-term):** Miniflare in front of cf-proxy for real CF emulation (WAF, BotID, rate-limit, cf-tunnel headers). LocalStack for the EC2 provisioner. Anonymized prod-traffic recording/replay for SaaS-scale regression detection. diff --git a/tests/harness/_curl.sh b/tests/harness/_curl.sh new file mode 100644 index 00000000..6a32ab5d --- /dev/null +++ b/tests/harness/_curl.sh @@ -0,0 +1,82 @@ +# Sourceable helper for harness replays. Centralises the +# curl-against-cf-proxy pattern so scripts don't depend on /etc/hosts. +# +# Production CF tunnel routes by Host header, not by DNS — the request +# URL is to a public CF endpoint and the Host header carries the +# per-tenant identity. We replay the same shape locally: +# +# curl -H "Host: harness-tenant.localhost" http://localhost:8080/health +# +# This matches what cf-proxy/nginx.conf already routes (`server_name +# *.localhost localhost`) and avoids the macOS /etc/hosts requirement +# that previously gated the harness behind a sudo step. +# +# Backwards-compatible: if /etc/hosts resolves harness-tenant.localhost +# (the legacy path), the bare URL still works because the helper falls +# back to that. New scripts SHOULD use the helper functions. +# +# Usage: +# HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$HERE/../_curl.sh" # from replays/.sh +# curl_admin "$BASE/health" +# curl_anon "$BASE/health" + +# Bind to the cf-proxy's loopback port — the proxy front-doors every +# tenant and routes by Host header, exactly like production's CF tunnel. +: "${BASE:=http://localhost:8080}" +: "${TENANT_HOST:=harness-tenant.localhost}" +: "${ADMIN_TOKEN:=harness-admin-token}" +: "${ORG_ID:=harness-org}" + +# Anonymous request — only Host header (no auth). Use for /health, +# /buildinfo, and any other route that's intentionally public. +curl_anon() { + curl -sS -H "Host: ${TENANT_HOST}" "$@" +} + +# Admin-token request — full SaaS auth shape. Sets the bearer token, +# tenant org header (activates TenantGuard middleware), and a default +# JSON Content-Type. Replays admin paths exactly the way CP does in +# production, so any TenantGuard / strict-auth bug surfaces locally. +curl_admin() { + curl -sS \ + -H "Host: ${TENANT_HOST}" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + -H "X-Molecule-Org-Id: ${ORG_ID}" \ + -H "Content-Type: application/json" \ + "$@" +} + +# Workspace-scoped request — uses a per-workspace bearer minted from +# /admin/workspaces/:id/test-token. The platform's auth.go middleware +# accepts this bearer for the workspace's own routes, so this is the +# right shape for replays that exercise an in-workspace tool calling +# back to the platform (chat_history, list_peers, etc). +# +# Caller must export WORKSPACE_TOKEN before invoking. +curl_workspace() { + : "${WORKSPACE_TOKEN:?WORKSPACE_TOKEN must be set — mint via /admin/workspaces/:id/test-token}" + curl -sS \ + -H "Host: ${TENANT_HOST}" \ + -H "Authorization: Bearer ${WORKSPACE_TOKEN}" \ + -H "X-Molecule-Org-Id: ${ORG_ID}" \ + -H "Content-Type: application/json" \ + "$@" +} + +# Direct postgres exec — for replays that need to seed activity_logs +# rows or read DB state that has no public HTTP route. Wraps the +# `docker compose exec` pattern so replays can stay shell-only. +# +# SECRETS_ENCRYPTION_KEY is set to a placeholder so compose's `:?must +# be set` interpolation guard (which gates running the harness without +# up.sh) doesn't trip on `exec` — exec only reaches an already-running +# service so the env var is irrelevant, but compose still validates +# the file. The placeholder is never written anywhere or used by any +# service. +psql_exec() { + SECRETS_ENCRYPTION_KEY="${SECRETS_ENCRYPTION_KEY:-exec-placeholder}" \ + docker compose -f "${HARNESS_COMPOSE:-$(dirname "${BASH_SOURCE[0]}")/compose.yml}" \ + exec -T postgres \ + psql -U harness -d molecule -At "$@" +} diff --git a/tests/harness/replays/buildinfo-stale-image.sh b/tests/harness/replays/buildinfo-stale-image.sh index 9d9be053..af6cd497 100755 --- a/tests/harness/replays/buildinfo-stale-image.sh +++ b/tests/harness/replays/buildinfo-stale-image.sh @@ -22,12 +22,12 @@ set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HARNESS_ROOT="$(dirname "$HERE")" - -BASE="${BASE:-http://harness-tenant.localhost:8080}" +# shellcheck source=../_curl.sh +source "$HARNESS_ROOT/_curl.sh" # 1. Confirm /buildinfo wire shape — same shape the workflow's jq lookup expects. echo "[replay] curl $BASE/buildinfo ..." -BUILD_JSON=$(curl -sS "$BASE/buildinfo") +BUILD_JSON=$(curl_anon "$BASE/buildinfo") echo "[replay] $BUILD_JSON" ACTUAL_SHA=$(echo "$BUILD_JSON" | jq -r '.git_sha // ""') diff --git a/tests/harness/replays/channel-envelope-trust-boundary.sh b/tests/harness/replays/channel-envelope-trust-boundary.sh new file mode 100755 index 00000000..550def4c --- /dev/null +++ b/tests/harness/replays/channel-envelope-trust-boundary.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Replay for the channel envelope peer_id trust-boundary fix +# (PR #2481, follow-up to PR #2471). Verifies that the PUBLISHED wheel +# installed on this machine — not local source — gates malformed peer_id +# at both the envelope builder and the agent_card_url builder. +# +# Why this matters: +# - Unit tests in workspace/tests/ run against local source. They +# prove the fix works in source. They DO NOT prove the published +# wheel contains the fix. +# - The wheel rewriter (scripts/build_runtime_package.py) renames +# symbols + paths. Any rewrite drift could silently strip the +# guard from the shipped artifact. +# - This replay imports from `molecule_runtime.a2a_mcp_server` (the +# wheel-rewritten path), exercises the actual published code, and +# asserts the envelope shape. If the wheel build ever ships without +# the guard, this fails — even if unit tests on local source pass. +# +# Phases: +# A. Confirm an installed molecule-runtime version that contains the +# #2481 fix (>= 0.1.78). +# B. Call `_build_channel_notification` with peer_id="../../foo" and +# assert (1) meta["peer_id"] == "", (2) no agent_card_url field, +# (3) no peer_name/peer_role. +# C. Symmetric case: peer_id with embedded XML-attribute injection +# bytes — assert the same scrubbing. +# D. Happy path: a valid UUID peer_id is preserved (proves we didn't +# regress legitimate enrichment). +# E. Direct check on the URL builder — `_agent_card_url_for("../../foo")` +# must return "" and never an unsanitised URL. + +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HARNESS_ROOT="$(dirname "$HERE")" +cd "$HARNESS_ROOT" +# shellcheck source=../_curl.sh +source "$HARNESS_ROOT/_curl.sh" + +PASS=0 +FAIL=0 + +assert() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + printf " PASS %s\n" "$desc" + PASS=$((PASS + 1)) + else + printf " FAIL %s\n expected: %s\n got : %s\n" "$desc" "$expected" "$actual" >&2 + FAIL=$((FAIL + 1)) + fi +} + +# ─── Phase A: wheel version contains the fix ─────────────────────────── +echo "[replay] A. confirming installed molecule-ai-workspace-runtime contains #2481..." +INSTALLED=$(pip3 show molecule-ai-workspace-runtime 2>/dev/null | awk -F': ' '/^Version:/ {print $2}') +if [ -z "$INSTALLED" ]; then + echo "[replay] FAIL A: molecule-ai-workspace-runtime not installed." + echo " Install: pip3 install molecule-ai-workspace-runtime" + exit 2 +fi +echo "[replay] installed version: $INSTALLED" + +# 0.1.78 is the first published version after #2481 merged to staging. +# Compare via Python distutils-style version sort (works across patch +# bumps without sed-fragility). +HAS_FIX=$(python3 -c " +from packaging.version import parse +print('yes' if parse('$INSTALLED') >= parse('0.1.78') else 'no') +" 2>/dev/null || echo "unknown") +if [ "$HAS_FIX" != "yes" ]; then + echo "[replay] FAIL A: installed $INSTALLED < 0.1.78 (the version that shipped the #2481 fix)." + echo " Upgrade: pip3 install --upgrade molecule-ai-workspace-runtime" + exit 2 +fi +echo "[replay] ✓ contains #2481 trust-boundary fix" + +# ─── Phase B-E: in-process assertions against the installed wheel ────── +# We don't need WORKSPACE_ID/PLATFORM_URL/MOLECULE_WORKSPACE_TOKEN to +# import the module — the env validation only fires at console-script +# entry. We use molecule_runtime.* (the wheel-rewritten import path) +# rather than workspace.a2a_mcp_server (local source) so this exercises +# the SHIPPED code. +echo "" +echo "[replay] B-E. exercising _build_channel_notification + _agent_card_url_for from the installed wheel..." + +OUT=$(WORKSPACE_ID=00000000-0000-0000-0000-000000000000 \ + PLATFORM_URL=http://localhost:8080 \ + MOLECULE_WORKSPACE_TOKEN=stub \ + MOLECULE_MCP_DISABLE_HEARTBEAT=1 \ + python3 - <<'PYEOF' +import json +import sys + +from molecule_runtime.a2a_mcp_server import _build_channel_notification +from molecule_runtime.a2a_client import _agent_card_url_for + +results = [] + +def emit(name, value): + results.append({"name": name, "value": value}) + +# ── B: path-traversal peer_id stripped from envelope ── +payload = _build_channel_notification({ + "peer_id": "../../foo", + "kind": "peer_agent", + "text": "redirect-attempt", + "activity_id": "act-1", + "method": "message/send", + "created_at": "2026-05-01T00:00:00Z", +}) +meta = payload["params"]["meta"] +emit("B1_peer_id_scrubbed", meta.get("peer_id", "")) +emit("B2_agent_card_url_absent", "absent" if "agent_card_url" not in meta else meta["agent_card_url"]) +emit("B3_peer_name_absent", "absent" if "peer_name" not in meta else meta["peer_name"]) +emit("B4_peer_role_absent", "absent" if "peer_role" not in meta else meta["peer_role"]) + +# ── C: XML-attribute-injection-shape peer_id ── +payload = _build_channel_notification({ + "peer_id": 'aaa" onclick="alert(1)', + "kind": "peer_agent", + "text": "xss", +}) +meta = payload["params"]["meta"] +emit("C1_peer_id_scrubbed", meta.get("peer_id", "")) +emit("C2_agent_card_url_absent", "absent" if "agent_card_url" not in meta else "leaked") + +# ── D: legitimate UUID is preserved ── +valid_uuid = "11111111-2222-3333-4444-555555555555" +payload = _build_channel_notification({ + "peer_id": valid_uuid, + "kind": "peer_agent", + "text": "legit", +}) +meta = payload["params"]["meta"] +emit("D1_peer_id_preserved", meta.get("peer_id", "")) +# agent_card_url IS present (we don't gate the URL itself on whether the registry is reachable) +emit("D2_agent_card_url_present", "yes" if meta.get("agent_card_url", "").endswith(valid_uuid) else "no") + +# ── E: direct URL builder gate ── +emit("E1_url_builder_strips_traversal", _agent_card_url_for("../../foo")) +emit("E2_url_builder_strips_xml", _agent_card_url_for('a" onclick="x')) +emit("E3_url_builder_accepts_uuid_endswith", "yes" if _agent_card_url_for(valid_uuid).endswith(valid_uuid) else "no") + +print(json.dumps(results)) +PYEOF +) + +# Parse and assert each result. +echo "$OUT" | python3 -c " +import json, sys +results = json.loads(sys.stdin.read()) +for r in results: + print(f\"{r['name']}={r['value']}\") +" > /tmp/cha-envelope-results.txt + +while IFS='=' read -r key value; do + case "$key" in + B1_peer_id_scrubbed) assert "B1: malicious peer_id scrubbed to \"\"" "" "$value" ;; + B2_agent_card_url_absent) assert "B2: agent_card_url not emitted" "absent" "$value" ;; + B3_peer_name_absent) assert "B3: peer_name not enriched" "absent" "$value" ;; + B4_peer_role_absent) assert "B4: peer_role not enriched" "absent" "$value" ;; + C1_peer_id_scrubbed) assert "C1: XML-injection peer_id scrubbed" "" "$value" ;; + C2_agent_card_url_absent) assert "C2: XML-injection URL not emitted" "absent" "$value" ;; + D1_peer_id_preserved) assert "D1: valid UUID peer_id preserved" "11111111-2222-3333-4444-555555555555" "$value" ;; + D2_agent_card_url_present) assert "D2: agent_card_url present for valid id" "yes" "$value" ;; + E1_url_builder_strips_traversal) assert "E1: _agent_card_url_for(\"../../foo\") returns \"\"" "" "$value" ;; + E2_url_builder_strips_xml) assert "E2: _agent_card_url_for(XML-injection) returns \"\"" "" "$value" ;; + E3_url_builder_accepts_uuid_endswith) assert "E3: _agent_card_url_for(valid uuid) builds canonical URL" "yes" "$value" ;; + esac +done < /tmp/cha-envelope-results.txt + +echo "" +if [ "$FAIL" -gt 0 ]; then + echo "[replay] FAIL: $PASS pass, $FAIL fail" + echo "" + echo "[replay] If B/C/E failed: the published wheel does NOT contain the #2481 fix." + echo "[replay] Likely causes:" + echo " - Wheel rewriter dropped _validate_peer_id from molecule_runtime.a2a_client" + echo " - publish-runtime.yml regressed to a SHA before #2481 (check pip install version)" + exit 1 +fi +echo "[replay] PASS: $PASS/$PASS — channel envelope peer_id trust boundary holds in published wheel $INSTALLED" diff --git a/tests/harness/replays/chat-history.sh b/tests/harness/replays/chat-history.sh new file mode 100755 index 00000000..d6efa571 --- /dev/null +++ b/tests/harness/replays/chat-history.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Replay for the chat_history MCP tool — exercises the full SaaS-shape +# wire that PRs #2472 (peer_id filter), #2474 (chat_history client), and +# #2476 (before_ts paging) ride on. Runs against the prod-shape tenant +# image, not unit-mock'd handlers, so any drift between the Go handler +# and the Python tool's expectations surfaces here. +# +# What this catches that unit tests don't: +# - Real Postgres planner behaviour on the (source_id = $X OR target_id = $X) +# OR clause (issue #2478 — both indexes missing). +# - cf-proxy header rewrites + TenantGuard middleware in the path. +# - lib/pq + Postgres driver type binding for time.Time parameters. +# - JSON encoding of created_at across the wire (timezone, precision). +# +# Phases: +# A. Seed three a2a_receive rows for alpha with peer_id=beta, spread +# across distinct timestamps. +# B. Basic peer_id filter: GET ?type=a2a_receive&peer_id=beta&limit=10 +# → assert 3 rows DESC. +# C. Limit cap: limit=2 → assert 2 newest rows. +# D. before_ts paging: take the 2nd-newest's created_at, GET with +# before_ts=that → assert the 1 strictly-older row. +# E. OR clause (target side): seed an a2a_send row where source=alpha, +# target=beta. GET with type unset, peer_id=beta → assert that row +# surfaces too (target_id match, not just source_id). +# F. Trust-boundary: peer_id="not-a-uuid" → 400 + "peer_id must be a UUID". +# G. Trust-boundary: before_ts="garbage" → 400 + RFC3339 example. +# H. URL-encoded SQL-injection-shape peer_id → 400 (matches activity_test.go's +# malicious-peer-id panel). + +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HARNESS_ROOT="$(dirname "$HERE")" +cd "$HARNESS_ROOT" + +if [ ! -f .seed.env ]; then + echo "[replay] no .seed.env — running ./seed.sh first..." + ./seed.sh +fi +# shellcheck source=/dev/null +source .seed.env +# shellcheck source=../_curl.sh +source "$HARNESS_ROOT/_curl.sh" + +PASS=0 +FAIL=0 + +assert() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + printf " PASS %s\n" "$desc" + PASS=$((PASS + 1)) + else + printf " FAIL %s\n expected: %s\n got : %s\n" "$desc" "$expected" "$actual" >&2 + FAIL=$((FAIL + 1)) + fi +} + +assert_contains() { + local desc="$1" needle="$2" haystack="$3" + if echo "$haystack" | grep -qF "$needle"; then + printf " PASS %s\n" "$desc" + PASS=$((PASS + 1)) + else + printf " FAIL %s\n expected to contain: %s\n got: %s\n" "$desc" "$needle" "$haystack" >&2 + FAIL=$((FAIL + 1)) + fi +} + +echo "[replay] alpha=$ALPHA_ID beta=$BETA_ID" + +# ─── Phase A: seed the activity_logs table ───────────────────────────── +# Inserted via psql so the seed is independent of the platform's HTTP +# Notify path — that path itself ships through the same handler chain +# we want to test, and seeding through it would conflate setup and +# assertion. +echo "" +echo "[replay] A. seeding 3 a2a_receive rows for alpha←beta at distinct timestamps..." +psql_exec >/dev/null </dev/null </dev/null </dev/null +ALPHA_ID=$(curl_admin -X POST "$BASE/workspaces" \ + -d '{"name":"alpha","tier":0,"runtime":"langgraph"}' \ + | jq -r '.id') +if [ -z "$ALPHA_ID" ] || [ "$ALPHA_ID" = "null" ]; then + echo "[seed] FAIL: alpha workspace creation returned no id" + exit 1 +fi echo "[seed] alpha id=$ALPHA_ID" echo "[seed] creating workspace 'beta' (child of alpha)..." -BETA_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') -curl_admin -X POST "$BASE/workspaces" \ - -d "{\"id\":\"$BETA_ID\",\"name\":\"beta\",\"tier\":1,\"parent_id\":\"$ALPHA_ID\",\"runtime\":\"langgraph\"}" \ - >/dev/null +BETA_ID=$(curl_admin -X POST "$BASE/workspaces" \ + -d "{\"name\":\"beta\",\"tier\":1,\"parent_id\":\"$ALPHA_ID\",\"runtime\":\"langgraph\"}" \ + | jq -r '.id') +if [ -z "$BETA_ID" ] || [ "$BETA_ID" = "null" ]; then + echo "[seed] FAIL: beta workspace creation returned no id" + exit 1 +fi echo "[seed] beta id=$BETA_ID" # Stash IDs so replay scripts pick them up. diff --git a/tests/harness/up.sh b/tests/harness/up.sh index fbc14910..87a6cf91 100755 --- a/tests/harness/up.sh +++ b/tests/harness/up.sh @@ -41,15 +41,18 @@ fi echo "[harness] starting cp-stub + postgres + redis + tenant + cf-proxy ..." docker compose -f compose.yml up -d --wait -echo "[harness] /etc/hosts entry for harness-tenant.localhost..." -if ! grep -q '^127\.0\.0\.1[[:space:]]\+harness-tenant\.localhost' /etc/hosts; then - echo " (skip — your /etc/hosts may not resolve *.localhost. If tests fail with" - echo " 'getaddrinfo' errors, add: 127.0.0.1 harness-tenant.localhost)" -fi - +# Sudo-free reachability: cf-proxy/nginx routes by Host header (matches +# production CF tunnel), so replays target loopback :8080 with a Host +# header rather than depending on /etc/hosts resolution. _curl.sh +# centralises this. Legacy /etc/hosts users still work — the BASE env +# var override accepts either shape. echo "" -echo "[harness] up. Tenant: http://harness-tenant.localhost:8080/health" -echo " http://harness-tenant.localhost:8080/buildinfo" -echo " cp-stub: http://localhost (internal-only via compose net)" +echo "[harness] up." +echo " Tenant via cf-proxy: http://localhost:8080/health" +echo " (Host: harness-tenant.localhost)" +echo " cp-stub: internal-only via compose net" +echo "" +echo " Quick check:" +echo " curl -H 'Host: harness-tenant.localhost' http://localhost:8080/health" echo "" echo "Next: ./seed.sh # mint admin token + register sample workspaces"