Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,35 @@ jobs:
- run: pnpm run typecheck
- run: pnpm run test
- run: pnpm run build

conformance:
name: Conformance (ts)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Build the TS router
run: pnpm -C routers/typescript build

- uses: astral-sh/setup-uv@v5

- name: Set up the conformance harness
working-directory: conformance
run: |
uv venv --python 3.10 .venv
uv pip install --python .venv/bin/python schemathesis pytest httpx jsonschema

- name: Run conformance against the TS router
working-directory: conformance
env:
ROUTER: ts
run: .venv/bin/python -m pytest -q
7 changes: 6 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@ pnpm-lock.yaml
CHANGELOG.md
LICENSE
NOTICE
.github/CODEOWNERS
.github/CODEOWNERS

# Python harness tooling (conformance venv + caches) — not ours to format
**/.venv
**/.pytest_cache
**/__pycache__
4 changes: 4 additions & 0 deletions conformance/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv/
__pycache__/
.pytest_cache/
.hypothesis/
98 changes: 98 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# `/embed` conformance suite

Proves that every backend router behaves identically and matches the
[`/embed` contract](../packages/embed/openapi/embed.openapi.yaml). It is
**language-agnostic** — it boots a router under test and drives it over HTTP, so
the same suite runs against the TypeScript, Go, and Python routers (and any
future one).

## How it works

```
mock_monad.py ── a stateful in-memory stand-in for the Monad API
│ MONAD_API_BASE
router under test ── booted by conftest.py (ROUTER=python|go|ts), mounted at /embed
│ HTTP
tests ── test_conformance.py (Schemathesis) + test_scenarios.py (lifecycle)
+ test_mock_fidelity.py (mock ⟷ Monad shapes)
```

- **`test_conformance.py`** — Schemathesis reads the `/embed` OpenAPI spec,
generates requests for every operation, and asserts each response matches the
documented **status code, response schema, and content type**.
- **`test_scenarios.py`** — the stateful lifecycle Schemathesis's stateless
fuzzing can't cover: mint → build ingress → status → disable → status →
remove → status, plus egress.
- **`test_mock_fidelity.py`** — pins the mock to the **Monad** response shapes
the routers consume (`monad_schemas.py`, vendored from Monad's OpenAPI), so the
mock can't drift from the documented upstream contract on a field a router
parses. This is the check that would have caught a missing `pagination`
sibling for free. (Needs `jsonschema`.)

The router is booted with a stubbed `getCustomerOrgID` (always the tenant
`org_conf`) and provisioning (`destinationOutputId=out_store`, `sourceInputId=in_source`),
pointed at the mock. Nothing touches real Monad.

## Two contracts, two directions

The suite guards both contracts the router sits between:

- **downstream** — `test_conformance.py` proves the router's `/embed` responses
match _our_ published contract (`packages/embed/openapi/embed.openapi.yaml`).
- **upstream** — `test_mock_fidelity.py` proves the _mock_ matches _Monad's_
documented shapes. But a spec is only as good as its accuracy: Monad's spec
types the connectors list as `array` yet real Monad returns `null` when empty
(the bug that hit the Python router). So spec-fidelity alone is not enough —
**live mode** (below) closes the gap by running the routers against real Monad.

## Live mode — run the routers against real Monad

The default run is hermetic (mock). Set `MONAD_LIVE=1` and point the same env at
real Monad staging to run the **identical** read-path scenarios against the real
API — the only thing that catches where reality diverges from the spec:

```sh
MONAD_LIVE=1 \
MONAD_API_BASE=https://app.monad.security/api \
MONAD_API_KEY=<staging key> \
MONAD_ORG_ID=<tenant org> \
MONAD_SOURCE_ID=<provisioned input id> \
MONAD_FRAME_ORIGIN=https://app.monad.security/embed \
ROUTER=python .venv/bin/python -m pytest -q
```

In live mode the mock is not booted; Schemathesis is restricted to **read-only
GET** operations (no fuzzed mutations against real Monad); and the mutating
lifecycle scenarios are skipped unless you opt in with `MONAD_LIVE_MUTATE=1`
plus throwaway `CONF_INPUT_ID` / `CONF_OUTPUT_ID` components (they create and
then delete real pipelines, keeping the shared source). Every knob defaults to
the mock fixture value, so an unset env is exactly the hermetic run.

## Run

```sh
# one-time: create the harness venv (installs Schemathesis + the Python router)
uv venv --python 3.10 .venv
uv pip install --python .venv/bin/python schemathesis pytest httpx fastapi uvicorn -e ../routers/python

./run.sh # all routers: python go ts
./run.sh python go # a subset
ROUTER=ts .venv/bin/python -m pytest -q # a single router directly
```

Prerequisites per router: **python** — none (uses the harness venv); **go** —
a Go toolchain (`go run ./cmd/conformance`); **ts** — the built package
(`run.sh` runs `pnpm -C packages/embed build` automatically).

## Scope note — auth and negative input

The suite runs Schemathesis's **response-conformance** checks
(`status_code_conformance`, `response_schema_conformance`,
`content_type_conformance`). It deliberately does **not** run the
`ignored_auth` or `negative_data_rejection` checks: auth is the host's
responsibility — each router mounts _behind_ the host's auth middleware and
trusts `getCustomerOrgID`, which the harness stubs — so those checks would test the
stub, not the router. A `5xx` is a documented, conformant outcome here
(`500 internal_error`, `502 upstream_error`).
84 changes: 84 additions & 0 deletions conformance/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Conformance harness fixtures.

Boots the stateful mock Monad plus the router under test (selected by the
``ROUTER`` env var: ``python`` | ``go`` | ``ts``), pointed at the mock, then
yields for the tests. Language-agnostic: every test drives the router over HTTP.
"""

from __future__ import annotations

import os
import signal
import subprocess
import time
import urllib.request

import pytest

import mock_monad

ROUTER = os.environ.get("ROUTER", "python")
MOCK_PORT = int(os.environ.get("MOCK_PORT", "8790"))
ROUTER_PORT = int(os.environ.get("ROUTER_PORT", "8791"))

# Live mode boots the router against the real Monad API (MONAD_API_BASE +
# MONAD_API_KEY + MONAD_ORG_ID … supplied by the caller) instead of the mock.
LIVE = os.environ.get("MONAD_LIVE") == "1"

_HERE = os.path.dirname(os.path.abspath(__file__))
_REPO = os.path.abspath(os.path.join(_HERE, ".."))


def _command() -> tuple[list[str], str, dict[str, str]]:
env = dict(os.environ)
# In mock mode we own MONAD_API_BASE (point the router at the local mock);
# in live mode the caller's real MONAD_API_BASE is passed through untouched.
if not LIVE:
env["MONAD_API_BASE"] = f"http://127.0.0.1:{MOCK_PORT}"
env["PORT"] = str(ROUTER_PORT)
if ROUTER == "python":
return [os.path.join(_HERE, ".venv", "bin", "python"), os.path.join(_HERE, "servers", "py_server.py")], _HERE, env
if ROUTER == "go":
return ["go", "run", "./cmd/conformance"], os.path.join(_REPO, "routers", "go"), env
if ROUTER == "ts":
return ["node", os.path.join(_HERE, "servers", "ts_server.mjs")], _HERE, env
raise ValueError(f"unknown ROUTER={ROUTER!r} (expected python|go|ts)")


def _wait_healthy(url: str, timeout: float = 45.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=1) as resp:
if resp.status < 500:
return True
except Exception: # noqa: BLE001 — server not up yet
time.sleep(0.15)
return False


@pytest.fixture(scope="session", autouse=True)
def servers():
# Live mode talks to real Monad — no mock to boot.
server = None if LIVE else mock_monad.start(MOCK_PORT)[0]
cmd, cwd, env = _command()
# New session so we can kill the whole group (e.g. `go run` + its child binary).
proc = subprocess.Popen(cmd, cwd=cwd, env=env, start_new_session=True)
try:
if not _wait_healthy(f"http://127.0.0.1:{ROUTER_PORT}/embed/config"):
raise RuntimeError(f"router '{ROUTER}' did not become healthy on port {ROUTER_PORT}")
yield
finally:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except ProcessLookupError:
pass
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass
if server is not None:
server.shutdown()
Loading
Loading