#!/usr/bin/env bash # End-to-end test of authentication and the org -> farm -> printer # authorization chain, against a real database and the real HTTP routes. # # This is the test that the unit tests cannot be. tests/authz.test.ts proves # tokens are verified and that the role ladder orders correctly; only this # proves that each route actually asks, that scoping is applied in the query # rather than after it, and that credentials stay out of responses - the three # things that were wrong when every route was unauthenticated. # # Four users, two organisations, no printers or gateways needed: # owner_a, operator_a, viewer_a in org A, which owns farm A and printer A # outsider_b in org B, which owns nothing # # Usage: # scripts/dev-env.sh up && scripts/dev-env.sh schema # scripts/e2e-authz.sh # # KEEP_DATA=1 leave the seeded organisations behind for inspection set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" PORT="${PORT:-3020}" AUTH_PORT="${AUTH_PORT:-3021}" 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}" # Presigning a PUT is local HMAC with no network call, so POST /jobs works # offline - but the SDK refuses to sign with empty credentials. R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}" R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}" R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}" export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT export R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT WORK="$(mktemp -d)" BACKEND_PID="" AUTH_PID="" FAILURES=0 # `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"; } cleanup() { local rc=$? # Never `kill $PID` with an unset PID: `kill 0` signals the whole process # group, which includes this script and whatever launched it. local pids=() [ -n "$BACKEND_PID" ] && pids+=("$BACKEND_PID") [ -n "$AUTH_PID" ] && pids+=("$AUTH_PID") if [ ${#pids[@]} -gt 0 ]; then kill "${pids[@]}" 2>/dev/null || true sleep 0.3 kill -9 "${pids[@]}" 2>/dev/null || true fi if [ -z "${KEEP_DATA:-}" ]; then for org in "${ORG_A:-}" "${ORG_B:-}"; do [ -n "$org" ] && psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true done fi if [ $rc -ne 0 ]; then echo echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true fi rm -rf "$WORK" exit $rc } trap cleanup EXIT fail() { echo "FAIL: $*" >&2; exit 1; } ok() { echo " ok: $*"; } bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); } # `req [json body]` -> prints the status code, # leaves the response body in $WORK/last.json. req() { local method="$1" path="$2" token="$3" body="${4:-}" local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$method" "$BASE$path") [ "$token" != "-" ] && args+=(-H "authorization: Bearer $token") [ -n "$body" ] && args+=(-H 'content-type: application/json' -d "$body") curl "${args[@]}" } # `expect [body] -- ` expect() { local want="$1"; shift local method="$1" path="$2" token="$3"; shift 3 local body="" if [ "${1:-}" != "--" ]; then body="$1"; shift; fi shift # the -- local what="$*" local got; got="$(req "$method" "$path" "$token" "$body")" if [ "$got" = "$want" ]; then ok "$what ($got)" else bad "$what: expected $want, got $got - $(head -c 300 "$WORK/last.json")" fi } body_has() { grep -q "$1" "$WORK/last.json" || bad "expected '$1' in: $(head -c 300 "$WORK/last.json")"; } body_lacks() { grep -q "$1" "$WORK/last.json" && bad "'$1' leaked into: $(head -c 300 "$WORK/last.json")" || true; } json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; } # --- 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 org_id FROM farms LIMIT 0" >/dev/null 2>&1 \ || fail "farms.org_id is missing - run scripts/dev-env.sh schema" psql_q "SELECT role FROM org_members LIMIT 0" >/dev/null 2>&1 \ || fail "org_members is missing - run scripts/dev-env.sh schema" ok "schema has the org layer" # --- 1. the issuer and the 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" mint() { curl -sf "$AUTH_URL/token?sub=$1" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; } OWNER_A_ID="$(new_uuid)"; OWNER_A="$(mint "$OWNER_A_ID")" OPERATOR_A_ID="$(new_uuid)"; OPERATOR_A="$(mint "$OPERATOR_A_ID")" VIEWER_A_ID="$(new_uuid)"; VIEWER_A="$(mint "$VIEWER_A_ID")" OUTSIDER_ID="$(new_uuid)"; OUTSIDER="$(mint "$OUTSIDER_ID")" ok "minted four access tokens" 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 curl -sf "$BASE/health" >/dev/null 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")" ok "backend healthy" # --- 2. anonymous access -------------------------------------------------- echo "==> nothing works without a token" expect 200 GET /health - -- "GET /health is public" expect 401 GET /orgs - -- "GET /orgs rejects anonymous" expect 401 GET /farms - -- "GET /farms rejects anonymous" expect 401 GET /printers - -- "GET /printers rejects anonymous" expect 401 GET /jobs - -- "GET /jobs rejects anonymous" expect 401 GET /auth/session - -- "GET /auth/session rejects anonymous" expect 401 POST /farms - '{"name":"x","orgId":"00000000-0000-0000-0000-000000000000"}' -- "POST /farms rejects anonymous" expect 401 GET /farms -H.invalid.token -- "a malformed token is rejected" # --- 3. the seeded world -------------------------------------------------- echo "==> building org A (via the API, as owner_a)" expect 201 POST /orgs "$OWNER_A" '{"name":"e2e org A"}' -- "owner_a creates org A" ORG_A="$(json 'd["id"]')" body_has '"role":"owner"' ok "org A = $ORG_A, creator is its owner" expect 200 GET /auth/session "$OWNER_A" -- "owner_a reads their session" body_has "$ORG_A" expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_A_ID" "$OWNER_A" '{"role":"operator"}' -- "owner_a adds operator_a" expect 200 PUT "/orgs/$ORG_A/members/$VIEWER_A_ID" "$OWNER_A" '{"role":"viewer"}' -- "owner_a adds viewer_a" expect 201 POST /farms "$OWNER_A" "{\"name\":\"e2e farm A\",\"orgId\":\"$ORG_A\"}" -- "owner_a creates farm A" FARM_A="$(json 'd["id"]')" body_lacks edgeProxyToken ok "farm A = $FARM_A, and the create response carries no proxy token" expect 201 POST /printers "$OWNER_A" \ "{\"farmId\":\"$FARM_A\",\"name\":\"e2e printer A\",\"vendor\":\"bambu\",\"accessCode\":\"SECRET123\",\"serialNumber\":\"SN-SECRET\"}" \ -- "owner_a adds printer A with an access code" PRINTER_A="$(json 'd["id"]')" body_lacks SECRET123 ok "printer A = $PRINTER_A, and the create response carries no access code" echo "==> building org B (via the API, as the outsider)" expect 201 POST /orgs "$OUTSIDER" '{"name":"e2e org B"}' -- "outsider creates their own org B" ORG_B="$(json 'd["id"]')" # --- 4. scoping: a list shows only your own ------------------------------ echo "==> lists are scoped to the caller's organisations" expect 200 GET /farms "$VIEWER_A" -- "viewer_a lists farms" body_has "$FARM_A" expect 200 GET /farms "$OUTSIDER" -- "the outsider lists farms" body_lacks "$FARM_A" ok "the outsider's farm list does not contain farm A" expect 200 GET /printers "$OUTSIDER" -- "the outsider lists printers" body_lacks "$PRINTER_A" ok "the outsider's printer list does not contain printer A" expect 200 GET /orgs "$OUTSIDER" -- "the outsider lists organisations" body_lacks "$ORG_A" ok "the outsider cannot see org A exists" # --- 5. a non-member gets 404, not 403 ----------------------------------- echo "==> a non-member cannot distinguish 'forbidden' from 'does not exist'" expect 404 GET "/farms/$FARM_A" "$OUTSIDER" -- "GET another org's farm" expect 404 GET "/printers/$PRINTER_A" "$OUTSIDER" -- "GET another org's printer" expect 404 GET "/orgs/$ORG_A" "$OUTSIDER" -- "GET another org" expect 404 GET "/orgs/$ORG_A/members" "$OUTSIDER" -- "GET another org's members" expect 404 DELETE "/farms/$FARM_A" "$OUTSIDER" -- "DELETE another org's farm" expect 404 PATCH "/printers/$PRINTER_A" "$OUTSIDER" '{"name":"hijacked"}' -- "PATCH another org's printer" expect 404 PUT "/orgs/$ORG_A/members/$OUTSIDER_ID" "$OUTSIDER" '{"role":"owner"}' -- "add themselves to another org" expect 404 POST "/farms/$FARM_A/edge-proxy-token" "$OUTSIDER" -- "rotate another farm's gateway credential" expect 404 POST "/printers/$PRINTER_A/commands" "$OUTSIDER" '{"action":"set_fan_speed","percent":100}' \ -- "command another org's printer" # The same id, as a member: proves the 404s above are about membership and not # about the farm being missing or the route being broken. expect 200 GET "/farms/$FARM_A" "$VIEWER_A" -- "the same farm id, as a member of its org" # --- 6. the role ladder, over HTTP --------------------------------------- echo "==> roles gate what a member can do" expect 200 GET "/printers/$PRINTER_A" "$VIEWER_A" -- "a viewer reads a printer" expect 403 POST "/printers/$PRINTER_A/commands" "$VIEWER_A" '{"action":"set_fan_speed","percent":100}' \ -- "a viewer cannot command a printer" body_has FORBIDDEN # 409 PRINTER_UNREACHABLE, not 403: no gateway is connected in this test, so # authorization passing is exactly what reaching that error means. expect 409 POST "/printers/$PRINTER_A/commands" "$OPERATOR_A" '{"action":"set_fan_speed","percent":100}' \ -- "an operator passes authorization and fails on delivery" body_has PRINTER_UNREACHABLE expect 403 POST "/jobs" "$VIEWER_A" "{\"farmId\":\"$FARM_A\",\"fileName\":\"v.3mf\"}" -- "a viewer cannot submit a job" expect 201 POST "/jobs" "$OPERATOR_A" "{\"farmId\":\"$FARM_A\",\"fileName\":\"o.3mf\"}" -- "an operator submits a job" JOB_A="$(json 'd["job"]["id"]')" SUBMITTER="$(json 'd["job"]["submittedBy"]')" [ "$SUBMITTER" = "$OPERATOR_A_ID" ] \ && ok "submittedBy came from the token, not the body ($SUBMITTER)" \ || bad "submittedBy is '$SUBMITTER', expected '$OPERATOR_A_ID'" # The old route took `submittedBy` from the body. Sending it now must not # change the recorded submitter - an ignored extra field, not an override. expect 201 POST "/jobs" "$OPERATOR_A" \ "{\"farmId\":\"$FARM_A\",\"fileName\":\"spoof.3mf\",\"submittedBy\":\"$OWNER_A_ID\"}" \ -- "a job body claiming another submitter" SPOOFED="$(json 'd["job"]["submittedBy"]')" [ "$SPOOFED" = "$OPERATOR_A_ID" ] \ && ok "a spoofed submittedBy in the body is ignored" \ || bad "the body overrode submittedBy: got '$SPOOFED'" expect 403 PATCH "/farms/$FARM_A" "$OPERATOR_A" '{"name":"renamed by operator"}' -- "an operator cannot rename a farm" expect 200 PATCH "/farms/$FARM_A" "$OWNER_A" '{"name":"e2e farm A renamed"}' -- "an owner can rename a farm" expect 403 POST "/printers" "$OPERATOR_A" "{\"farmId\":\"$FARM_A\",\"name\":\"nope\",\"vendor\":\"bambu\"}" \ -- "an operator cannot add a printer" # --- 7. credentials stay on the server ------------------------------------ echo "==> credentials never appear in an ordinary response" expect 200 GET "/farms/$FARM_A" "$OWNER_A" -- "an owner fetches their own farm" body_lacks edgeProxyToken expect 200 GET /farms "$OWNER_A" -- "an owner lists their farms" body_lacks edgeProxyToken expect 200 GET "/printers/$PRINTER_A" "$OWNER_A" -- "an owner fetches their own printer" body_lacks SECRET123 body_lacks accessCode ok "no proxy token and no access code in any read" echo "==> rotation is owner-only, and returns the new value exactly once" expect 403 POST "/farms/$FARM_A/edge-proxy-token" "$OPERATOR_A" -- "an operator cannot rotate" expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER_A" -- "an owner rotates" ROTATED="$(json 'd["edgeProxyToken"]')" [ -n "$ROTATED" ] || bad "rotation returned no token" STORED="$(psql_q "SELECT edge_proxy_token FROM farms WHERE id='$FARM_A'")" [ "$ROTATED" = "$STORED" ] && ok "the returned token is the one now stored" || bad "rotation did not persist" # And it still works for what it is for. CODE="$(req POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$ROTATED\"}")" [ "$CODE" = "200" ] && ok "the rotated token still mints an edge JWT (no user token needed)" \ || bad "POST /auth/edge-token returned $CODE for the rotated token" expect 401 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"not-the-token\"}" \ -- "a wrong proxy token is refused" # --- 8. the guards that stop an org becoming unadministrable -------------- echo "==> an organisation cannot be left without an owner" expect 409 DELETE "/orgs/$ORG_A/members/$OWNER_A_ID" "$OWNER_A" -- "the only owner cannot remove themselves" expect 409 PUT "/orgs/$ORG_A/members/$OWNER_A_ID" "$OWNER_A" '{"role":"viewer"}' -- "the only owner cannot demote themselves" expect 409 DELETE "/orgs/$ORG_A" "$OWNER_A" -- "an org with farms attached cannot be deleted" body_has CONFLICT expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_A_ID" "$OWNER_A" '{"role":"owner"}' -- "promote a second owner" expect 200 DELETE "/orgs/$ORG_A/members/$OWNER_A_ID" "$OWNER_A" -- "now the first owner may leave" expect 404 GET "/orgs/$ORG_A" "$OWNER_A" -- "and immediately loses access to the org" # --- 9. pagination is bounded -------------------------------------------- echo "==> list endpoints are bounded" expect 422 GET "/jobs?limit=10000" "$OPERATOR_A" -- "an oversized limit is refused, not silently clamped" expect 200 GET "/jobs?limit=1" "$OPERATOR_A" -- "a limit of 1 is honoured" COUNT="$(json 'len(d)')" [ "$COUNT" = "1" ] && ok "one row returned" || bad "limit=1 returned $COUNT rows" echo if [ "$FAILURES" -eq 0 ]; then echo "PASS - authentication, org scoping, the role ladder and credential" echo " containment all verified against a real database." else echo "FAIL - $FAILURES assertion(s) failed." >&2 exit 1 fi