Files
continuum-backend/scripts/e2e-authz.sh
T
octoturge 0e6a4aaa93 Record that this client cannot run behind pgCat 1.2.0, and stop lying about it
A real pooled database became available, so the deployment assumption in
src/db/index.ts - "points at pgCat/PgBouncer, so keep prepared statements
disabled" - was finally testable. It does not hold.

Through pgCat 1.2.0 an unparameterised `select 1` succeeds, and so does
`select count(*) from organisations`, but any query carrying a bind
parameter hangs forever. That is true with prepare:false, with
prepare:true, with fetch_types:false, and inside an explicit
transaction. The same queries straight at Postgres all pass, and psql's
\bind through the same pool returns its row, so the fault is specific to
how postgres-js frames those messages rather than to extended protocol.
Every Drizzle query binds parameters, so no subset of this app works
there. drizzle-kit hung at commit for the same reason - it builds its
own client and drizzle.config.ts cannot pass prepare:false - having
executed all 14 statements of 0000, which then rolled back.

Transaction-mode pooling itself was confirmed, not assumed: in one
client session consecutive statements hopped between server backends
while statements inside BEGIN..COMMIT stayed pinned to one.

So the comment now records what was measured, names PgBouncer as the
pooler this configuration actually suits, and says migrations go
straight at Postgres. dev-env.sh's note gets the same correction.

Two real bugs found on the way there:

- e2e-authz.sh and e2e-edge.sh hardcoded `psql -h localhost -U continuum
  -d continuum` while both read DATABASE_URL for the backend under test.
  Point DATABASE_URL elsewhere and the suite seeds fixtures into one
  database while exercising another - and its own failure message says
  "no database at $DATABASE_URL", naming a URL nothing ever tried. Both
  now use `psql "$DATABASE_URL"`.

- drizzle.config.ts fell back to port 6432, where nothing listens here,
  so a bare db:migrate failed confusingly. It now matches dev-env.sh's
  DEV_DATABASE_URL.

bun test 128 pass / 0 fail; e2e-authz.sh PASS with 68 checks against
local Postgres after the psql_q change. e2e-edge.sh was not re-run
because it drives the real printers, and its change is the same line.
2026-10-20 09:39:39 +00:00

335 lines
16 KiB
Bash
Executable File

#!/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
}
# Uses DATABASE_URL rather than a hardcoded localhost. The backend under test
# reads that variable, so a psql pointed anywhere else seeds fixtures into a
# different database than the one being exercised - and the failure message
# below names DATABASE_URL, which would then be naming a URL nothing tried.
psql_q() { psql "$DATABASE_URL" -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 <method> <path> <token|-> [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 <code> <method> <path> <token> [body] -- <description>`
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"
# `accessCode` is deliberately still sent, even though the column was removed
# (2026-10-19) and the field is now unknown to the create schema. That is the
# point: a client that believes it can hand the cloud a printer's LAN password
# must have it dropped and never see it again. The schema is open, so the
# field validates and Elysia cleans it away rather than rejecting the request
# - which is behaviour worth proving against a real server, not just against
# the schema (see the credentials block in tests/authz.test.ts).
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, trying to set an access code"
PRINTER_A="$(json 'd["id"]')"
body_lacks SECRET123
ok "printer A = $PRINTER_A, and the submitted access code was dropped, not echoed"
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