Files
continuum-backend/scripts/e2e-edge.sh
T
octoturge d8f8653dee Authenticate and authorize every route: org layer + Supabase JWKS
Until now every HTTP route was unauthenticated. A probe against the
running server returned a farm's `edgeProxyToken` and a printer's
`accessCode` to an anonymous caller, and that pair is enough to connect
as a farm's gateway and drive its printers. The previous commit stopped
the credentials appearing in responses; this one makes the routes ask
who is calling.

Authentication. Access tokens are verified against the Supabase
project's published JWKS via `createSupabaseJwtVerifier` from
`@continuum/ts-core`: RS256, public key only, no secret in this
process. It replaces the HS256 `@elysiajs/jwt` path, where
`SUPABASE_JWT_SECRET` was simultaneously the verification key and a
key that could mint tokens, present in the environment of every
instance. `src/auth/verifier.ts` builds it at module load so a
misconfigured deployment fails at boot rather than on first login.

The edge-gateway token stays symmetric and that is not an
inconsistency: this service is both its issuer and its only verifier,
so there is no second party to keep a secret from. It now carries an
`exp`, which it did not, despite the response saying
`expiresInSeconds: 3600`.

Tenancy. An org layer above farms - `organisations`,
`org_members(org_id, user_id, role)`, `farms.org_id` - so a farm can
have more than one operator without sharing an account. `farms.owner_id`
is gone. Authorization is one rule applied everywhere: resolve the
thing being acted on back to an organisation and check the caller's
role there, user -> org -> farm -> printer, with nothing in between
carrying permissions of its own. The ladder is
`viewer` < `operator` < `owner`; printer commands and job submission
need `operator`, because they move physical machines, and that is the
one boundary here that is not about data.

A non-member gets 404, not 403: a 403 would confirm that a given farm
or printer id exists and belongs to someone, which is an enumeration
oracle over the whole system. 403 is kept for a member who may see the
record but not do this to it.

List endpoints scope in the `where` clause, not after the query - a
filter applied to the result is one `return` away from being skipped.
They are also bounded now (`limit`/`offset`, default 50, max 200
refused rather than clamped).

`ownerId` and `submittedBy` no longer come from request bodies. Nor do
the printer telemetry columns: `POST /printers` is narrowed to the
columns a client owns, because a client that could POST
`status: "idle"` could make the scheduler send work to a printer that
is mid-print.

Also fills the CRUD gaps that made the API unusable - PATCH and DELETE
for farms, printers, jobs and orgs, and membership management - plus
one error shape for the whole API via `toErrorResponse`, so an
authorization failure is a throw that leaves the handler rather than a
status somebody has to remember to set.

Guards on the two changes that cannot be undone through the API:
removing or demoting an organisation's last owner, and deleting an
organisation while farms are still attached (it would cascade to every
printer, print job and stored frame).

Migration 0001 is hand-edited after generation. drizzle-kit emitted
`ADD COLUMN org_id uuid NOT NULL`, which cannot apply to a populated
table, and dropped `owner_id` without moving what it encodes anywhere.
It now adds the column nullable, creates one organisation per distinct
legacy owner with that user as its `owner` member, points each farm at
it, and only then tightens the column and drops `owner_id`. Verified
both ways: from empty via `drizzle-kit migrate`, and against a seeded
database where two farms shared an owner and a third did not.

Testing. `scripts/dev-auth.ts` is a local RS256 issuer publishing a
JWKS at the path the backend fetches, so the real verification path is
testable without a Supabase project. The alternative was a test-only
bypass in the backend - an env var making it trust an unsigned token -
which would be a permanent hole one misconfiguration from production,
and would mean the code under test is not the code that ships.

- `tests/authz.test.ts` (19 tests): the role ladder written out in full
  rather than derived the same way the implementation derives it, and
  the real JWKS path - a token signed by a key outside the JWKS, an
  expired token and a token with `aud: "anon"` (Supabase's own anon key
  is a valid project JWT) are all rejected, with the same message, so
  probing cannot tell which part of a forged token to fix next.
- `scripts/e2e-authz.sh` (68 assertions, real Postgres, real routes):
  four users across two organisations; anonymous 401s, cross-org 404s,
  viewer-cannot-command, operator-cannot-rotate, a spoofed
  `submittedBy` in the body ignored, no credential in any read, and the
  rotated token still minting an edge JWT.
- `scripts/e2e-edge.sh` updated for the org layer and now authenticates
  as a seeded `operator` - the weakest role that should work, so the
  run would fail if the command boundary were not where it claims to
  be. Re-verified against both live printers: telemetry from `h2c` and
  `p1p` persisted, and an authenticated `set_fan_speed` went
  HTTP -> WebSocket -> MQTT -> P1P and back `ok:true`, fan returned
  to 0.

Two bugs found in the test scripts while writing this, both the same
shape as the earlier `pkill -f` incident: `kill "${PID:-0}"` with an
unset PID signals the whole process group, including the harness, and
`uuidgen` is not installed in this sandbox.
2026-10-03 15:49:29 +00:00

302 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# End-to-end test of the /ws/edge/v1 contract against a real database and real
# printers: seed a farm, claim its printers, mint an edge token, run the actual
# continuum-proxy daemon, and assert that live telemetry reached Postgres and
# that a command sent over HTTP reached a printer.
#
# This is the test that the unit tests cannot be: tests/edge_protocol.test.ts
# only proves the two ends agree about the wire format. Nothing but this proves
# the printer-id translation, persistTelemetry and sendPrinterCommand actually
# work against a database.
#
# SAFETY: the only command it sends is a fan speed, and only to the printer
# named by PRINTER_ID (default p1p), which it refuses to send unless that
# printer reports IDLE. It never starts, pauses or cancels a print. Point it at
# a printer you can see.
#
# Usage:
# scripts/dev-env.sh up && scripts/dev-env.sh schema
# scripts/e2e-edge.sh
#
# PRINTER_ID=p1p which printer to send the fan command to
# PROXY_DIR=../continuum-proxy
# KEEP_DATA=1 leave the seeded farm behind for inspection
#
# The HTTP routes now require a verified Supabase access token, so this starts
# scripts/dev-auth.ts - a local RS256 issuer publishing a JWKS at the path the
# backend fetches - and mints a token for a seeded org `operator`. That is the
# real verification path, not a bypass: see the header of dev-auth.ts.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
PRINTER_ID="${PRINTER_ID:-p1p}"
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
PORT="${PORT:-3010}"
AUTH_PORT="${AUTH_PORT:-3011}"
BASE="http://localhost:$PORT"
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
WORK="$(mktemp -d)"
BACKEND_PID=""
PROXY_PID=""
AUTH_PID=""
# `uuidgen` is not installed everywhere; the kernel's generator always is.
new_uuid() {
if [ -r /proc/sys/kernel/random/uuid ]; then
cat /proc/sys/kernel/random/uuid
else
python3 -c 'import uuid; print(uuid.uuid4())'
fi
}
psql_q() { PGPASSWORD=continuum psql -h localhost -U continuum -d continuum -tAq -c "$1"; }
# The farm's operator. A real Supabase user id would come from the project;
# here it only has to be a uuid that the token's `sub` and the org_members row
# agree on.
USER_ID="$(new_uuid)"
cleanup() {
local rc=$?
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do
[ -z "$pid" ] && continue
kill "$pid" 2>/dev/null || true
done
# A gateway that outlives this script keeps reconnecting with a token for a
# farm that is about to be deleted, and pollutes the next run's log.
for _ in $(seq 1 10); do
[ -n "$PROXY_PID" ] || break
kill -0 "$PROXY_PID" 2>/dev/null || break
sleep 0.3
done
# Never `kill $PID` with an unset PID: `kill 0` signals the whole process
# group, which includes this script and whatever launched it.
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true
done
if [ -z "${KEEP_DATA:-}" ] && [ -n "${ORG_ID:-}" ]; then
# Farms cascade from the organisation, printers from the farm.
psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
fi
if [ $rc -ne 0 ]; then
echo
echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true
echo "--- dev-auth log (tail) ---"; tail -10 "$WORK/auth.log" 2>/dev/null || true
echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true
fi
rm -rf "$WORK"
exit $rc
}
trap cleanup EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
# --- 0. preconditions ------------------------------------------------------
echo "==> checking the database is up and migrated"
psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up"
psql_q "SELECT gateway_printer_id FROM printers LIMIT 0" >/dev/null 2>&1 \
|| fail "printers.gateway_printer_id is missing - run scripts/dev-env.sh schema"
psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \
|| fail "farms.org_id is missing - run scripts/dev-env.sh schema"
ok "schema has the edge columns and the org layer"
[ -f "$PROXY_DIR/printers.toml" ] || fail "no $PROXY_DIR/printers.toml - the proxy needs real printers"
# --- 1. seed ---------------------------------------------------------------
echo "==> seeding an organisation, a farm and claiming its printers"
EDGE_PROXY_TOKEN="e2e-$(date +%s)"
ORG_ID="$(psql_q "INSERT INTO organisations (name) VALUES ('e2e org') RETURNING id")"
[ -n "$ORG_ID" ] || fail "could not create an organisation"
# `operator`, not `owner`: the command route requires operator, so seeding the
# weakest role that should work proves the boundary is where it claims to be
# rather than passing because the test user can do everything.
psql_q "INSERT INTO org_members (org_id, user_id, role)
VALUES ('$ORG_ID', '$USER_ID', 'operator')" >/dev/null
FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token)
VALUES ('e2e farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")"
[ -n "$FARM_ID" ] || fail "could not create a farm"
ok "org $ORG_ID, farm $FARM_ID, operator $USER_ID"
# gateway_printer_id is the whole point: it maps the gateway's own ids from
# printers.toml onto these rows. Telemetry for an id nothing claims is skipped.
while read -r pid pname; do
[ -z "$pid" ] && continue
psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id)
VALUES ('$FARM_ID', '$pname', 'bambu', '$pid')" >/dev/null
ok "claimed '$pid' as '$pname'"
done < <(awk -F'"' '/^id *=/ {id=$2} /^name *=/ {print id, $2}' "$PROXY_DIR/printers.toml")
# --- 2. backend ------------------------------------------------------------
echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT"
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
AUTH_PID=$!
AUTH_URL="http://localhost:$AUTH_PORT"
for _ in $(seq 1 40); do
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break
sleep 0.25
done
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \
|| fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")"
export SUPABASE_URL="$AUTH_URL"
ok "issuer up, SUPABASE_URL=$SUPABASE_URL"
USER_TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')"
[ -n "$USER_TOKEN" ] || fail "could not mint a user token"
AUTH_HEADER="authorization: Bearer $USER_TOKEN"
ok "minted an access token for the operator"
echo "==> starting continuum-backend on :$PORT"
bun run src/index.ts > "$WORK/backend.log" 2>&1 &
BACKEND_PID=$!
for _ in $(seq 1 60); do
curl -sf "$BASE/health" >/dev/null 2>&1 && break
sleep 0.5
done
HEALTH="$(curl -sf "$BASE/health")" || fail "backend did not become healthy"
echo "$HEALTH" | grep -q '"db":true' || fail "backend reports the database is down: $HEALTH"
echo "$HEALTH" | grep -q '"valkey":true' || fail "backend reports valkey is down: $HEALTH"
ok "health: $HEALTH"
# --- 3. edge token ---------------------------------------------------------
echo "==> minting an edge token"
TOKEN="$(curl -sf -X POST "$BASE/auth/edge-token" \
-H 'content-type: application/json' \
-d "{\"farmId\":\"$FARM_ID\",\"edgeProxyToken\":\"$EDGE_PROXY_TOKEN\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("token",""))')"
[ -n "$TOKEN" ] || fail "no token returned from POST /auth/edge-token"
ok "token minted (${#TOKEN} chars)"
# --- 4. the real gateway ---------------------------------------------------
echo "==> starting the real continuum-proxy against it"
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
# env -C rather than a `( cd ... ) &` subshell: $! has to be the daemon itself,
# or cleanup kills only the wrapper and leaves an orphan reconnecting with a
# stale token. An earlier version did exactly that, and the orphan then
# reported a deleted farm's printers into the next run.
env -C "$PROXY_DIR" \
CONTINUUM_GATEWAY_ID=e2e-gw \
CONTINUUM_FARM_ID="$FARM_ID" \
CONTINUUM_GATEWAY_TOKEN="$TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
CONTINUUM_GO2RTC_BIN=/nonexistent-go2rtc \
CONTINUUM_GO2RTC_CONFIG=./go2rtc.yaml \
CONTINUUM_PRINTERS_PATH=printers.toml \
RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$!
# Wait for *every* claimed printer, not just the first. Telemetry is on a 5s
# timer and a Bambu on protocol v1 reports one tick behind the rest (it only
# sends a full status when asked, so each tick reads the previous tick's
# answer) - stopping at the first row that appears is how an earlier version of
# this script silently tested only the v2 printer.
CLAIMED="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_ID' AND gateway_printer_id IS NOT NULL")"
echo "==> waiting for live telemetry from all $CLAIMED claimed printer(s)"
for _ in $(seq 1 45); do
COUNT="$(psql_q "SELECT count(*) FROM printers
WHERE farm_id='$FARM_ID' AND gateway_printer_id IS NOT NULL
AND last_seen_at IS NOT NULL AND vendor_state IS NOT NULL")"
[ "${COUNT:-0}" -ge "$CLAIMED" ] && break
sleep 1
done
[ "${COUNT:-0}" -ge 1 ] || fail "no telemetry was persisted at all (see the proxy log below)"
if [ "${COUNT:-0}" -lt "$CLAIMED" ]; then
# Not fatal: a printer that is genuinely switched off cannot report, and that
# should not fail a run against the ones that can. Name them so the
# difference between "off" and "broken" is visible.
echo " WARNING: only $COUNT of $CLAIMED printers reported. Silent:"
psql_q "SELECT ' - ' || gateway_printer_id FROM printers
WHERE farm_id='$FARM_ID' AND gateway_printer_id IS NOT NULL
AND (last_seen_at IS NULL OR vendor_state IS NULL)"
else
ok "all $COUNT claimed printer(s) reported live telemetry"
fi
if grep "reported unknown printer" "$WORK/backend.log" | grep -q "$FARM_ID"; then
fail "the backend could not map a gateway printer id for this farm - check gateway_printer_id"
fi
ok "every printer id this gateway reported mapped to a row"
echo "==> what landed in the database:"
PGPASSWORD=continuum psql -h localhost -U continuum -d continuum -c \
"SELECT gateway_printer_id AS gw, status, vendor_state, progress_percent AS pct,
current_layer || '/' || total_layers AS layers, remaining_minutes AS mins,
nozzle_temp_c AS nozzle, bed_temp_c AS bed, active_alerts AS alerts,
coalesce(current_job_name, '-') AS job
FROM printers WHERE farm_id = '$FARM_ID' ORDER BY gateway_printer_id"
# Proves the telemetry is real rather than a row touched with nulls.
psql_q "SELECT 1 FROM printers WHERE farm_id='$FARM_ID' AND vendor_state IS NOT NULL
AND nozzle_temp_c IS NOT NULL LIMIT 1" | grep -q 1 \
|| fail "rows were touched but carry no actual telemetry"
ok "rows carry real vendor state and temperatures"
# --- 5. a command, the whole way down --------------------------------------
TARGET_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID' AND gateway_printer_id='$PRINTER_ID'")"
[ -n "$TARGET_UUID" ] || fail "no printer claimed as '$PRINTER_ID'"
GW="$(curl -sf -H "$AUTH_HEADER" "$BASE/printers/$TARGET_UUID/gateway")"
echo "$GW" | grep -q '"gatewayOnline":true' || fail "the gateway is not registered as online: $GW"
ok "gateway reports online for $PRINTER_ID"
COMMAND_TESTED=no
STATE="$(psql_q "SELECT vendor_state FROM printers WHERE id='$TARGET_UUID'")"
if [ "$STATE" != "IDLE" ] && [ "$STATE" != "FINISH" ]; then
echo " SKIPPED the command test: $PRINTER_ID reports '${STATE:-no telemetry yet}', not IDLE/FINISH."
echo " Refusing to touch a printer that may be mid-print."
else
echo "==> sending set_fan_speed to $PRINTER_ID ($STATE) over HTTP"
RESP="$(curl -s -o "$WORK/cmd.json" -w '%{http_code}' -X POST \
"$BASE/printers/$TARGET_UUID/commands" -H 'content-type: application/json' \
-H "$AUTH_HEADER" -d '{"action":"set_fan_speed","percent":40}')"
[ "$RESP" = "200" ] || fail "command returned HTTP $RESP: $(cat "$WORK/cmd.json")"
grep -q '"ok":true' "$WORK/cmd.json" || fail "command was not acknowledged: $(cat "$WORK/cmd.json")"
ok "command acknowledged by the printer: $(cat "$WORK/cmd.json")"
# Put it back, so this leaves the printer as it found it.
curl -s -X POST "$BASE/printers/$TARGET_UUID/commands" \
-H 'content-type: application/json' -H "$AUTH_HEADER" \
-d '{"action":"set_fan_speed","percent":0}' > /dev/null
ok "fan returned to 0"
COMMAND_TESTED=yes
fi
# --- 6. the negative case --------------------------------------------------
echo "==> an unclaimed printer must be refused, not guessed at"
ORPHAN="$(psql_q "INSERT INTO printers (farm_id, name, vendor)
VALUES ('$FARM_ID', 'unclaimed', 'bambu') RETURNING id")"
CODE="$(curl -s -o "$WORK/orphan.json" -w '%{http_code}' -X POST \
"$BASE/printers/$ORPHAN/commands" -H 'content-type: application/json' \
-H "$AUTH_HEADER" -d '{"action":"set_fan_speed","percent":0}')"
[ "$CODE" = "409" ] || fail "expected HTTP 409 for an unclaimed printer, got $CODE"
grep -q 'PRINTER_UNREACHABLE' "$WORK/orphan.json" || fail "wrong error: $(cat "$WORK/orphan.json")"
ok "unclaimed printer refused with 409 PRINTER_UNREACHABLE"
echo
# Say what was actually exercised. Claiming commands work when the command test
# was skipped is worse than not running it.
if [ "$COMMAND_TESTED" = yes ]; then
echo "PASS - telemetry and commands both verified against a real database and real printers."
else
echo "PARTIAL PASS - telemetry verified against a real database and real printers."
echo " The command path was NOT exercised: no printer was idle enough to send to."
echo " Rerun with PRINTER_ID=<an idle printer> once one is free."
fi