diff --git a/.claude/skills/rtl-docker-fixture/SKILL.md b/.claude/skills/rtl-docker-fixture/SKILL.md index a715d8db..ac333923 100644 --- a/.claude/skills/rtl-docker-fixture/SKILL.md +++ b/.claude/skills/rtl-docker-fixture/SKILL.md @@ -33,8 +33,23 @@ bin/ln-cli bob fwdinghistory docker compose logs -f rtl ``` +Testing the **BTCPay Server integration** (RTL in single-sign-on mode behind a proxy) — +behind a compose profile, so a plain `up` does not start it: + +```bash +docker compose --profile sso up -d +./scripts/verify-sso.sh # 11 assertions over the whole entry path; non-zero on failure +open "$(bin/sso-url)" # the link BTCPay renders on its Services page +``` + Key facts when working with the fixture: +- **Run `scripts/verify-sso.sh` after touching authentication, CSRF or static serving.** + BTCPay reaches RTL over a path the standalone login never exercises — a rotating cookie + file, an unregistered `/rtl/api/authenticate/cookie` URL that falls through to the + catch-all in `server/utils/app.ts`, and a reverse proxy. Note `GET /rtl/` is served by + `express.static` and mints **no** `XSRF-TOKEN`; only the catch-all does, so a client + entering there 403s on its first POST. That is long-standing, not a regression. - **`scripts/seed.sh` is deterministic but not idempotent.** Every amount is fixed, so a fresh run always produces identical state (screenshots differ only by your change) — so **do not introduce randomness**. It refuses to run twice against an already-seeded diff --git a/CLAUDE.md b/CLAUDE.md index 2b869343..6fbba349 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,10 @@ frequently do, since the controllers were written in parallel. `ERESOLVE` conflict from `@fortawesome/angular-fontawesome`. - **`npm run server` only works on Windows** — it sets `NODE_ENV` with `set X=Y&&` syntax. On macOS/Linux use `npm run serverUbuntu`. -- **`npm run lint` and `npm run test` must both be green before a PR.** +- **`npm run lint` and `npm run test` must both be green before a PR.** Nothing will check + this for you: no build or test CI runs on an open PR. `checks.yml` fires on + `pull_request: closed` (i.e. on merge) and on tags/releases, and `rtlreviewbot` only on a + requested review or a comment — so running both locally is the only gate before merge. - If lint reports hundreds of template "Parsing error" failures, look for a stale **`coverage/`** directory (git-ignored Karma output). The template linter walks its HTML report. Delete it and re-run. @@ -91,6 +94,17 @@ Eclair, wired to RTL — for end-to-end testing across all three implementations `docker/README.md`, or the `rtl-docker-fixture` skill in `.claude/skills/`. It is dev-only; every credential in it is throwaway. +The fixture also carries a **BTCPay Server SSO harness** behind a compose profile +(`docker compose --profile sso up -d`). BTCPay bundles RTL and reaches it over a path the +standalone login never exercises: a rotating cookie file, an unregistered +`/rtl/api/authenticate/cookie` URL that is not a route at all and falls through to the +catch-all in `server/utils/app.ts`, and a reverse proxy serving it under `/rtl`. Run +`docker/scripts/verify-sso.sh` (11 assertions, exits non-zero) after touching +authentication, CSRF or static serving — none of that path is covered by logging into the +fixture's own RTL. One trap it encodes: `GET /rtl/` is served by `express.static`, which +sits above the catch-all and mints no `XSRF-TOKEN`, so a client entering there gets a 403 +on its first POST. That is long-standing behaviour, not a regression. + Backend regression tests live in `test/backend/` (plain `node:test`, run against the compiled `backend/`). `npm run test` compiles the backend, then runs them (`npm run testbackend`) before the frontend Karma/Jasmine specs, so they never test stale diff --git a/docker/README.md b/docker/README.md index b0de051d..7cf8ccb9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -74,6 +74,9 @@ docker compose up -d # bitcoind, alice, bob, carol, cln, eclair, rtl ./scripts/seed.sh # fund, connect, open channels, make payments ``` +To also bring up the BTCPay single-sign-on harness, add `--profile sso` — see +[BTCPay SSO harness](#btcpay-sso-harness). + Then open — password `rtldev`. All five nodes (alice, bob, carol, cln, eclair) appear in the node switcher. @@ -118,6 +121,7 @@ bin/ln-cli bob listchannels bin/ln-cli bob fwdinghistory # forwarding history bin/e-cli getinfo # eclair-cli bin/e-cli channels +bin/sso-url # BTCPay-style SSO link (needs --profile sso) docker compose exec cln lightning-cli --network=regtest listpeerchannels # Core Lightning ``` @@ -128,6 +132,94 @@ docker compose logs -f rtl docker compose logs alice ``` +## BTCPay SSO harness + +BTCPay Server bundles RTL and runs it in single-sign-on mode, reached through a very +different entry path than the standalone login: no password, a rotating cookie, and a +reverse proxy in front. That path has broken before without the standalone flow +noticing, so the fixture can reproduce it. + +It is behind a compose profile, so a plain `docker compose up -d` does not start it: + +```bash +docker compose --profile sso up -d +./scripts/verify-sso.sh # 11 assertions over the whole entry path +open "$(bin/sso-url)" # or click through it yourself +``` + +`bin/sso-url` prints the link BTCPay renders on its Services page. Following it lands +you in RTL already authenticated, against the `alice` node. + +### How the flow works + +```mermaid +sequenceDiagram + participant B as Browser + participant P as rtl-sso-proxy
(stands in for traefik) + participant R as rtl-sso
(RTL_SSO=1) + participant C as .cookie
(shared volume) + + R->>C: writes 64 random bytes at startup + Note over B: bin/sso-url reads the cookie —
BTCPay reads the same file + B->>P: GET /rtl/api/authenticate/cookie?access-key= + P->>R: same URI, prefix passed through + R-->>B: not a registered route → catch-all:
mints XSRF-TOKEN, serves index.html + B->>P: POST /rtl/api/authenticate
{ PASSWORD, sha256(access-key) } + P->>R: + R->>C: matches → rotates the cookie + R-->>B: JWT +``` + +The three services are `rtl-sso-config-init` (stages `rtl/RTL-Config.sso.json`, same +copy-into-a-volume dance as the standalone RTL), `rtl-sso` (RTL with `RTL_SSO=1`, +`RTL_COOKIE_PATH` and `LOGOUT_REDIRECT_LINK` — the env block is lifted verbatim from +BTCPay's own compose fragment), and `rtl-sso-proxy` (nginx standing in for BTCPay's +traefik). `RTL_IMAGE` overrides both RTL containers at once, so a branch build gets +tested through both entry paths. + +It is a second RTL container rather than a flag on the first because RTL picks one +authentication mode at startup — SSO and the password login cannot coexist in one +instance. Both are up at the same time on different ports. + +### Things this makes visible + +**No prefix stripping anywhere.** RTL is built with `` and mounts +every route under `baseHref '/rtl'`, so BTCPay's traefik — and the nginx here — pass +`/rtl/…` through unmodified. The proxy deliberately 404s everything outside `/rtl`, so +a request escaping the prefix shows up as a failure instead of being quietly served. + +**The entry URL is not a real route.** `/rtl/api/authenticate/cookie` matches nothing in +`server/routes/shared/authenticate.ts`; it falls through to the catch-all in +`server/utils/app.ts`, which is what mints the `XSRF-TOKEN` cookie and serves the SPA. +The access-key is the raw cookie file content — the frontend sha256s it before posting +and the backend compares against `sha256(cookieValue)`. + +**`GET /rtl/` mints no CSRF token.** That path is served by `express.static`, which +sits *above* the catch-all, so a client entering there has no `XSRF-TOKEN` and its first +POST gets a 403. Only the catch-all mints one. This is long-standing behaviour, not a +regression — but it is why `verify-sso.sh` always seeds its cookie jar from the entry +URL, and worth remembering before concluding that CSRF is broken. + +**The cookie is effectively single-use.** Authenticating rotates it, so a stale +`bin/sso-url` link fails. BTCPay re-reads the file on every page render, which is why +this is invisible in normal use. + +### What it does not cover + +BTCPay itself is not here — no postgres, nbxplorer or btcpayserver container. So this +does not exercise BTCPay *generating* the link, its Services page, or its own upgrades. +For that, run BTCPay's own regtest stack and point it at a local image: + +```bash +# in a btcpayserver-docker checkout, after building an RTL image locally +docker build -t shahanafarooqui/rtl:dev /path/to/RTL +# then edit the rtl image tag in the generated docker-compose, or set it in +# docker-compose-generator/docker-fragments/bitcoin-lnd.yml before generating +``` + +That tests the real composition rather than this reconstruction of it; the harness here +is the fast everyday check. + ## Notes and gotchas **RTL's config.** `rtl/RTL-Config.regtest.json` is the tracked template. RTL rewrites @@ -180,3 +272,7 @@ one LND uses, eclair never sees new blocks and channels never confirm. ## Not included The Boltz swap service. + +BTCPay Server itself (postgres + nbxplorer + btcpayserver). The `sso` profile +reproduces the entry path BTCPay uses to reach RTL without running BTCPay — see +[BTCPay SSO harness](#btcpay-sso-harness) for what that covers and what it does not. diff --git a/docker/bin/sso-url b/docker/bin/sso-url new file mode 100755 index 00000000..08d92870 --- /dev/null +++ b/docker/bin/sso-url @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# +# Print the BTCPay-style single-sign-on entry URL for the SSO harness. +# +# docker compose --profile sso up -d +# bin/sso-url # print it +# open "$(bin/sso-url)" # or follow it straight into RTL +# +# This is the link BTCPay renders on its Services page. BTCPay builds it from +# BTCPAY_BTCEXTERNALRTL="server=/rtl/api/authenticate/cookie;cookiefile=..." +# by reading the cookie file RTL wrote and appending it as ?access-key=. The +# value is the raw file content: RTL's frontend sha256s it before posting +# (src/app/app.component.ts) and the backend compares against +# sha256(cookieValue), so no hashing happens here. +# +# Authenticating rotates the cookie (common.refreshCookie), so re-run this for +# each login -- exactly as BTCPay re-reads the file on every page render. + +set -euo pipefail + +cd "$(dirname "$0")/.." +# shellcheck disable=SC1091 +[ -f .env ] && source .env + +port="${RTL_SSO_PORT:-3001}" + +if ! docker compose --profile sso ps --status running --services 2>/dev/null | grep -qx rtl-sso; then + echo "rtl-sso is not running. Start it with: docker compose --profile sso up -d" >&2 + exit 1 +fi + +cookie="$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')" + +if [ -z "$cookie" ]; then + echo "The cookie file /RTL/cookie/.cookie is empty. Is RTL_SSO=1 set on rtl-sso?" >&2 + exit 1 +fi + +echo "http://localhost:${port}/rtl/api/authenticate/cookie?access-key=${cookie}" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 55672171..e059ab3a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -17,6 +17,9 @@ volumes: eclair_data: rtl_db: rtl_config: + rtl_sso_db: + rtl_sso_config: + rtl_sso_cookie: x-lnd: &lnd image: polarlightning/lnd:0.20.0-beta @@ -285,7 +288,7 @@ services: container_name: ${COMPOSE_PROJECT_NAME}_rtl # Defaults to the published image; override with RTL_IMAGE (e.g. a locally built # branch image) to test unreleased changes: RTL_IMAGE=rtl:pr1625 docker compose up. - image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.8} + image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.10} restart: unless-stopped depends_on: rtl-config-init: @@ -311,3 +314,87 @@ services: - carol_data:/lnd/carol:ro - cln_data:/cln:ro - rtl_db:/RTL/database + + # --------------------------------------------------------------------------- + # BTCPay Server SSO harness -- profile "sso", so a plain 'up' does not start it: + # + # docker compose --profile sso up -d + # bin/sso-url + # + # BTCPay bundles RTL as a service and runs it in single-sign-on mode. RTL + # writes a random cookie to RTL_COOKIE_PATH; BTCPay reads that file and renders + # a link to /rtl/api/authenticate/cookie?access-key= on its Services + # page. That path is not a registered route -- it falls through to RTL's + # catch-all, which mints the XSRF-TOKEN cookie and serves index.html, and the + # SPA then reads access-key from the query string and posts it as a password + # login. Reproducing that entry path is the whole point of this harness: it is + # where RTL's auth, CSRF and static-serving behaviour meet an external caller, + # and it has broken before without the standalone flow noticing. + # + # BTCPay itself (postgres + nbxplorer + btcpayserver) is deliberately not here. + # See README.md, "BTCPay SSO harness", for what that leaves untested and how to + # run against a real BTCPay when it matters. + # --------------------------------------------------------------------------- + + # Same copy-into-a-volume dance as rtl-config-init above, and for the same + # reason: RTL rewrites its config on startup. + rtl-sso-config-init: + container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso_config_init + profiles: ["sso"] + image: busybox:1.36 + command: > + sh -c "cp /template/RTL-Config.sso.json /config/RTL-Config.json && + chmod 644 /config/RTL-Config.json && + echo 'sso config staged'" + volumes: + - ./rtl/RTL-Config.sso.json:/template/RTL-Config.sso.json:ro + - rtl_sso_config:/config + + # A second RTL rather than a flag on the first: RTL picks one authentication + # mode at startup, so SSO and the password login cannot coexist in one + # instance. Only alice is wired up, matching BTCPay's one-node-per-RTL layout. + # + # The environment block is copied from BTCPay's own compose fragment + # (docker-compose-generator/docker-fragments/bitcoin-lnd.yml in + # btcpayserver-docker), so this exercises the env-driven SSO path BTCPay + # actually uses rather than the config-file equivalent -- which is why + # RTL-Config.sso.json leaves its SSO block zeroed and carries no multiPass. + # + # Only 'expose'd, never published: all access goes through the proxy, as it + # does under BTCPay. + rtl-sso: + container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso + profiles: ["sso"] + image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.10} + restart: unless-stopped + depends_on: + rtl-sso-config-init: + condition: service_completed_successfully + alice: + condition: service_started + environment: + RTL_CONFIG_PATH: /RTL/config + RTL_SSO: 1 + RTL_COOKIE_PATH: /RTL/cookie/.cookie + LOGOUT_REDIRECT_LINK: /server/services + expose: + - "3000" + volumes: + - rtl_sso_config:/RTL/config + - rtl_sso_cookie:/RTL/cookie + - alice_data:/lnd/alice:ro + - rtl_sso_db:/RTL/database + + # Stands in for BTCPay's traefik. Routes only /rtl and /rtl/*, mirroring the + # label BTCPay puts on its RTL container; see nginx/rtl-sso.conf. + rtl-sso-proxy: + container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso_proxy + profiles: ["sso"] + image: nginx:1.27-alpine + restart: unless-stopped + depends_on: + - rtl-sso + ports: + - "${RTL_SSO_PORT:-3001}:80" + volumes: + - ./nginx/rtl-sso.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/docker/nginx/rtl-sso.conf b/docker/nginx/rtl-sso.conf new file mode 100644 index 00000000..314aa605 --- /dev/null +++ b/docker/nginx/rtl-sso.conf @@ -0,0 +1,46 @@ +# Reverse proxy in front of RTL running in BTCPay Server's single-sign-on mode. +# +# Stands in for the traefik instance BTCPay puts in front of its bundled RTL. +# The location regex mirrors the router rule BTCPay labels that container with: +# +# Host(`${BTCPAY_HOST}`) && (Path(`/rtl`) || PathPrefix(`/rtl/`)) +# +# so only /rtl and /rtl/* are proxied and everything else 404s here. That +# strictness is deliberate: RTL is built with (angular.json) +# and mounts every route under baseHref '/rtl' (server/utils/common.ts), so a +# request that escapes the prefix is a bug the harness should surface rather +# than quietly serve. +# +# The prefix is passed through unmodified -- there is no strip-prefix step to +# get wrong, because RTL expects to see it. proxy_pass without a URI part +# preserves the original request URI. + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name _; + + location ~ ^/rtl(/|$) { + proxy_pass http://rtl-sso:3000; + + # RTL runs with Express 'trust proxy' enabled, so these are what it sees + # as the client address in its logs and rate limiting. + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # RTL's websocket lives at /rtl/api/ws and stays open for the life of the + # session. Without the upgrade headers it fails the handshake and the UI + # silently stops receiving live updates; without the long read timeout + # nginx drops it after 60s. + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + } +} diff --git a/docker/rtl/RTL-Config.sso.json b/docker/rtl/RTL-Config.sso.json new file mode 100644 index 00000000..59b7b1b4 --- /dev/null +++ b/docker/rtl/RTL-Config.sso.json @@ -0,0 +1,30 @@ +{ + "port": "3000", + "defaultNodeIndex": 1, + "dbDirectoryPath": "/RTL/database", + "SSO": { + "rtlSSO": 0, + "rtlCookiePath": "", + "logoutRedirectLink": "" + }, + "nodes": [ + { + "index": 1, + "lnNode": "alice", + "lnImplementation": "LND", + "authentication": { + "macaroonPath": "/lnd/alice/data/chain/bitcoin/regtest" + }, + "settings": { + "userPersona": "OPERATOR", + "themeMode": "DAY", + "themeColor": "PURPLE", + "logLevel": "ERROR", + "lnServerUrl": "https://alice:8080", + "fiatConversion": false, + "unannouncedChannels": false, + "blockExplorerUrl": "https://mempool.space" + } + } + ] +} diff --git a/docker/scripts/verify-sso.sh b/docker/scripts/verify-sso.sh new file mode 100755 index 00000000..c9fe0e1f --- /dev/null +++ b/docker/scripts/verify-sso.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Check the BTCPay SSO harness end to end. +# +# Walks the exact path a browser takes when an operator clicks the RTL link on +# BTCPay Server's Services page, and asserts each step. Run it after any change +# to authentication, CSRF or static serving -- this is the entry path BTCPay +# uses, and none of it is covered by logging into the standalone fixture RTL. +# +# docker compose --profile sso up -d +# ./scripts/verify-sso.sh +# +# Usage: ./scripts/verify-sso.sh (from the docker/ directory) +# +# Exits non-zero if any check fails, so it can gate a PR. + +# Deliberately no -e: a failing assertion must record itself and let the rest of +# the checks run, rather than aborting on the first one. That means anything +# that would normally rely on -e needs its own guard. +set -uo pipefail + +cd "$(dirname "$0")/.." || exit 1 + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +BASE="http://localhost:${RTL_SSO_PORT:-3001}" +JAR="$(mktemp)" +JAR2="$(mktemp)" +trap 'rm -f "$JAR" "$JAR2"' EXIT + +pass=0 +fail=0 +# Both return 0 explicitly: the checks below are written as `test && ok || bad`, +# which would also run `bad` if `ok` itself ever returned non-zero. +ok() { echo " PASS: $1"; pass=$((pass + 1)); return 0; } +bad() { echo " FAIL: $1"; fail=$((fail + 1)); return 0; } + +if ! docker compose --profile sso ps --status running --services 2>/dev/null | grep -qx rtl-sso; then + echo "rtl-sso is not running. Start it with: docker compose --profile sso up -d" >&2 + exit 1 +fi + +cookie="$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')" +echo "cookie: ${cookie:0:16}... (${#cookie} chars)" + +echo +echo "1. the proxy routes only /rtl, mirroring BTCPay's traefik rule" +code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/") +[ "$code" = "404" ] && ok "GET / -> 404" || bad "GET / -> $code (want 404)" +code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/authenticate") +[ "$code" = "404" ] && ok "GET /api/authenticate -> 404" || bad "GET /api/authenticate -> $code (want 404)" +code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/rtl/") +[ "$code" = "200" ] && ok "GET /rtl/ -> 200" || bad "GET /rtl/ -> $code (want 200)" + +echo +echo "2. the entry URL falls through to the catch-all, which mints the CSRF token" +body=$(curl -s -c "$JAR" "$BASE/rtl/api/authenticate/cookie?access-key=$cookie") +echo "$body" | grep -q '' \ + && ok "entry URL serves the SPA shell with base href /rtl/" \ + || bad "entry URL did not serve index.html" +xsrf=$(awk '/XSRF-TOKEN/ {print $7}' "$JAR") +[ -n "$xsrf" ] && ok "XSRF-TOKEN cookie minted" || bad "no XSRF-TOKEN cookie" +grep -q '_csrf' "$JAR" && ok "_csrf cookie set" || bad "no _csrf cookie" + +echo +echo "3. the SPA posts sha256(access-key) as a password login" +hash=$(printf '%s' "$cookie" | shasum -a 256 | cut -d' ' -f1) +resp=$(curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/rtl/api/authenticate" \ + -H 'Content-Type: application/json' -H "X-XSRF-TOKEN: $xsrf" \ + -d "{\"authenticateWith\":\"PASSWORD\",\"authenticationValue\":\"$hash\"}") +token=$(echo "$resp" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))' 2>/dev/null) +[ -n "$token" ] && ok "authenticated, JWT issued" || bad "auth failed: $resp" + +echo +echo "4. the JWT reaches the node behind it" +info=$(curl -s -b "$JAR" "$BASE/rtl/api/lnd/getinfo" -H "Authorization: Bearer $token") +node_alias=$(echo "$info" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("alias",""))' 2>/dev/null) +[ "$node_alias" = "alice" ] && ok "GET /rtl/api/lnd/getinfo -> alias '$node_alias'" || bad "getinfo returned: ${info:0:200}" + +echo +echo "5. the cookie rotates on login, so each BTCPay page render hands out a fresh one" +after=$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n') +[ "$after" != "$cookie" ] && ok "cookie rotated after authentication" || bad "cookie did NOT rotate" + +echo +echo "6. a wrong access-key is refused" +# The token has to come from the catch-all: GET /rtl/ is served by express.static, +# which mints no XSRF-TOKEN, and the POST would then fail CSRF (403) before it +# ever reached the access-key comparison this step is checking. +curl -s -c "$JAR2" "$BASE/rtl/api/authenticate/cookie?access-key=x" > /dev/null +x2=$(awk '/XSRF-TOKEN/ {print $7}' "$JAR2") +badhash=$(printf '%s' "not-the-cookie-value-but-long-enough-to-pass-the-length-check" | shasum -a 256 | cut -d' ' -f1) +code=$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR2" -X POST "$BASE/rtl/api/authenticate" \ + -H 'Content-Type: application/json' -H "X-XSRF-TOKEN: $x2" \ + -d "{\"authenticateWith\":\"PASSWORD\",\"authenticationValue\":\"$badhash\"}") +[ "$code" = "406" ] && ok "wrong access-key -> 406" || bad "wrong access-key -> $code (want 406)" + +echo +echo "7. the standalone fixture RTL is unaffected" +code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${RTL_PORT:-3000}/rtl/") +[ "$code" = "200" ] && ok "standalone RTL still serving on ${RTL_PORT:-3000}" || bad "standalone RTL -> $code" + +echo +echo "=== $pass passed, $fail failed ===" +[ "$fail" -eq 0 ]