POST /farms/:id/printers (owner) is the console's add-printer call. It needs a connected gateway and relays the printer's access code to it in a single provision_printer frame; the gateway connects to the printer to prove the code, saves it in its own database, and only then does the cloud create the printer row. A refused or timed-out add leaves no row, and a row insert that fails after the gateway said yes tells the gateway to forget the printer. The access code is held in the request and the one frame and nowhere else: not in any table, not in a log line, not in a response or an error (the gateway's error text is scrubbed of it as a second line of defence). Deleting a printer now also tells the gateway to forget it and its stored credential, best effort, so an unplugged gateway cannot block an owner. Only Bambu is accepted, because it is the only vendor with a working driver; the rest are found by discovery but cannot yet be driven. e2e-provision.sh drives a stand-in gateway over the real socket and, after adding a printer, searches every table of the database and the backend's log for the code (checked to fail when the code is logged).
247 lines
11 KiB
Bash
Executable File
247 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# End-to-end test of adding a printer from the console: the access code must
|
|
# reach the gateway over the real /ws/edge/v1 and be kept nowhere in the cloud
|
|
# - not in any table, not in the backend's log, not in a response.
|
|
#
|
|
# 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:-3032}"
|
|
AUTH_PORT="${AUTH_PORT:-3033}"
|
|
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
|
|
echo "--- fake gateway log ---"; cat "$WORK/gw.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"])'; }
|
|
|
|
|
|
echo "==> starting continuum-backend on :$PORT"
|
|
DISPATCH_ENABLED=false 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. a farm, an owner, an operator -----------------------------------
|
|
|
|
OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")"
|
|
OPERATOR_ID="$(new_uuid)"; OPERATOR="$(mint "$OPERATOR_ID")"
|
|
OUTSIDER_ID="$(new_uuid)"; OUTSIDER="$(mint "$OUTSIDER_ID")"
|
|
expect 201 POST /orgs "$OWNER" '{"name":"e2e provision org"}' -- "create an org"
|
|
ORG_A="$(json 'd["id"]')"
|
|
expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_ID" "$OWNER" '{"role":"operator"}' -- "add an operator"
|
|
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e provision farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm"
|
|
FARM_A="$(json 'd["id"]')"
|
|
|
|
CODE="SECRETCODE-$(new_uuid | cut -c1-8)"
|
|
BODY="{\"name\":\"Bench P1P\",\"vendor\":\"bambu\",\"host\":\"192.168.0.50\",\"accessCode\":\"$CODE\",\"serialNumber\":\"01P00A000000001\"}"
|
|
|
|
echo "==> who may add a printer, and what happens with no gateway"
|
|
expect 403 POST "/farms/$FARM_A/printers" "$OPERATOR" "$BODY" -- "an operator cannot add a printer"
|
|
expect 404 POST "/farms/$FARM_A/printers" "$OUTSIDER" "$BODY" -- "an outsider cannot see the farm exists"
|
|
expect 401 POST "/farms/$FARM_A/printers" - "$BODY" -- "anonymous is refused"
|
|
expect 409 POST "/farms/$FARM_A/printers" "$OWNER" "$BODY" -- "no gateway connected: refused, nothing queued"
|
|
body_lacks "$CODE"
|
|
N="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_A'")"
|
|
[ "$N" = "0" ] && ok "no printer row was created" || bad "a row exists after a refused add: $N"
|
|
|
|
# --- 3. with a gateway -----------------------------------------------------
|
|
|
|
expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER" -- "mint the gateway token"
|
|
PROXY_TOKEN="$(json 'd["edgeProxyToken"]')"
|
|
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$PROXY_TOKEN\"}" -- "trade it for an edge JWT"
|
|
EDGE_JWT="$(json 'd["token"]')"
|
|
FRAMES="$WORK/frames.jsonl"; : > "$FRAMES"
|
|
REFUSED="REFUSEME-$(new_uuid | cut -c1-8)"
|
|
FARM_ID="$FARM_A" bun scripts/fake-gateway-serve.ts "ws://localhost:$PORT/ws/edge/v1" "$EDGE_JWT" 40 "$FRAMES" "$REFUSED" > "$WORK/gw.log" 2>&1 &
|
|
GW_PID=$!
|
|
for _ in $(seq 1 40); do grep -q ready "$WORK/gw.log" 2>/dev/null && break; sleep 0.25; done
|
|
grep -q ready "$WORK/gw.log" || fail "the fake gateway did not connect: $(cat "$WORK/gw.log")"
|
|
sleep 0.5
|
|
|
|
echo "==> adding a printer through the gateway"
|
|
expect 422 POST "/farms/$FARM_A/printers" "$OWNER" "${BODY/bambu/klipper}" -- "a vendor with no working driver is refused"
|
|
expect 422 POST "/farms/$FARM_A/printers" "$OWNER" "${BODY/192.168.0.50/not a host}" -- "a malformed address is refused"
|
|
expect 201 POST "/farms/$FARM_A/printers" "$OWNER" "$BODY" -- "the owner adds a printer"
|
|
PRINTER_ID="$(json 'd["id"]')"
|
|
GWID="$(json 'd["gatewayPrinterId"]')"
|
|
body_lacks "$CODE"
|
|
ok "the response does not contain the access code (printer $PRINTER_ID, gateway id $GWID)"
|
|
[ "$(json 'd["lanAddress"]')" = "192.168.0.50" ] && ok "address recorded" || bad "lanAddress wrong"
|
|
|
|
GOT="$(python3 -c "
|
|
import json,sys
|
|
for l in open('$FRAMES'):
|
|
m=json.loads(l)
|
|
if m['type']=='provision_printer': print(m['printer']['access_code'], m['printer']['id'], m['printer']['sn'])
|
|
")"
|
|
[ "$GOT" = "$CODE $GWID 01P00A000000001" ] && ok "the gateway received the access code, its id, and the serial" || bad "gateway frame was: '$GOT'"
|
|
|
|
echo "==> the cloud kept the credential nowhere"
|
|
LEAK_ROWS="$(psql_q "SELECT count(*) FROM printers WHERE row_to_json(printers)::text LIKE '%$CODE%'")"
|
|
[ "$LEAK_ROWS" = "0" ] && ok "not in the printers table" || bad "the access code is in the printers table"
|
|
# Every table, so a future column or table holding it cannot hide.
|
|
ALL_LEAK="$(psql "$DATABASE_URL" -tAq -c "SELECT string_agg(table_name, ',') FROM information_schema.tables WHERE table_schema='public'" \
|
|
| tr ',' '\n' | while read -r tbl; do [ -n "$tbl" ] && psql_q "SELECT count(*) FROM \"$tbl\" t WHERE row_to_json(t)::text LIKE '%$CODE%'" | sed "s/^/$tbl /"; done | awk '$2>0')"
|
|
[ -z "$ALL_LEAK" ] && ok "not in any table of the database" || bad "found in: $ALL_LEAK"
|
|
grep -q "$CODE" "$WORK/backend.log" && bad "the access code is in the backend log" || ok "not in the backend's log"
|
|
|
|
echo "==> a gateway that refuses leaves nothing behind"
|
|
REFUSE_BODY="${BODY/$CODE/$REFUSED}"
|
|
expect 502 POST "/farms/$FARM_A/printers" "$OWNER" "$REFUSE_BODY" -- "the printer rejected the code"
|
|
body_has GATEWAY_REFUSED
|
|
body_has "access code was rejected"
|
|
body_lacks "$REFUSED"
|
|
N="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_A'")"
|
|
[ "$N" = "1" ] && ok "still exactly one printer row" || bad "expected 1 printer row, found $N"
|
|
grep -q "$REFUSED" "$WORK/backend.log" && bad "the refused code is in the backend log" || ok "nor was the refused code logged"
|
|
|
|
echo "==> removing a printer tells the gateway"
|
|
expect 200 DELETE "/printers/$PRINTER_ID" "$OWNER" -- "the owner deletes the printer"
|
|
[ "$(json 'd["gatewayForgot"]')" = "True" ] && ok "the gateway confirmed it forgot the printer" || bad "gatewayForgot: $(cat "$WORK/last.json")"
|
|
grep -q "\"remove_printer\".*\"$GWID\"" "$FRAMES" && ok "the gateway received remove_printer for $GWID" || bad "no remove_printer frame for $GWID"
|
|
|
|
kill "$GW_PID" 2>/dev/null || true
|
|
|
|
echo
|
|
if [ "$FAILURES" -eq 0 ]; then
|
|
echo "PASS - the access code reaches the gateway and rests nowhere in the cloud."
|
|
else
|
|
echo "FAIL - $FAILURES assertion(s) failed." >&2
|
|
exit 1
|
|
fi
|