Files
continuum-backend/scripts/e2e-authz.sh
T
octoturge 847f7cb686 Raise alerts: failed or paused prints, and AI detections
Nothing subscribed to channel:print_alerts and nothing watched a printer
fail, so an operator away from the screen found out when they came back to
a ruined plate. Alerts are now rows in printer_alerts, listed at
GET /alerts (open=true for what still needs a person) and cleared with
POST /alerts/:id/acknowledge, which is operator-only and keeps the first
acknowledger on the record.

Two sources. Telemetry: a change into error is a failed print if the printer
was printing or paused, otherwise a printer error; a print the printer
paused itself (how an AMS fault looks) is announced too. Transitions, not
levels - a printer sitting in error is one alert, not one per frame - and a
connection's first sight of a printer raises nothing, so a gateway that
reconnects does not re-announce known faults. AI: the worker's Valkey
publications are stored when confidence is at least ALERT_MIN_CONFIDENCE
(default 0.6); the worker deliberately does not threshold, so the policy
lives here. A bad message costs one message, not the listener.

e2e-authz.sh gains the scoping/ack cases; new e2e-alerts.sh drives a stand-in
gateway over the real /ws/edge/v1 and the real Valkey channel (checked to
fail when the derivation is broken).
2026-10-29 14:56:24 +00:00

357 lines
17 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"
# --- 7b. alerts ---------------------------------------------------------
echo "==> alerts are scoped, and acknowledging is an operator's decision"
ALERT_A="$(new_uuid)"
psql_q "INSERT INTO printer_alerts (id, farm_id, printer_id, kind, message) VALUES ('$ALERT_A', '$FARM_A', '$PRINTER_A', 'print_failed', 'e2e: the print failed')" >/dev/null
expect 200 GET "/alerts?open=true" "$VIEWER_A" -- "a viewer lists open alerts"
body_has "$ALERT_A"
expect 200 GET "/alerts?open=true" "$OUTSIDER" -- "the outsider lists open alerts"
body_lacks "$ALERT_A"
ok "the outsider's alert list does not contain farm A's alert"
expect 404 POST "/alerts/$ALERT_A/acknowledge" "$OUTSIDER" -- "the outsider cannot acknowledge another org's alert"
expect 403 POST "/alerts/$ALERT_A/acknowledge" "$VIEWER_A" -- "a viewer cannot acknowledge"
expect 200 POST "/alerts/$ALERT_A/acknowledge" "$OPERATOR_A" -- "an operator acknowledges"
WHO="$(json 'd["acknowledgedBy"]')"
[ "$WHO" = "$OPERATOR_A_ID" ] && ok "acknowledgedBy came from the token" || bad "acknowledgedBy is '$WHO'"
expect 200 POST "/alerts/$ALERT_A/acknowledge" "$OWNER_A" -- "acknowledging again is harmless"
WHO2="$(json 'd["acknowledgedBy"]')"
[ "$WHO2" = "$OPERATOR_A_ID" ] && ok "the first acknowledger stays on the record" || bad "second ack overwrote the record: '$WHO2'"
expect 200 GET "/alerts?open=true" "$VIEWER_A" -- "an acknowledged alert leaves the open list"
body_lacks "$ALERT_A"
expect 401 GET /alerts - -- "GET /alerts rejects anonymous"
# --- 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