The e2e scripts handed the proxy a pre-minted edge JWT, which hid that the proxy could not exchange its configured token for one. It now does, so the scripts configure it the way a real gateway is configured: with the farm's edge proxy token.
278 lines
12 KiB
Bash
Executable File
278 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# End-to-end test of adding a printer with the REAL continuum-proxy as the
|
|
# gateway (e2e-provision.sh uses a stand-in): the provision frame must parse on
|
|
# the proxy, the proxy must try the printer, and the answer must come back.
|
|
#
|
|
# Without REAL_HOST/REAL_SERIAL/REAL_ACCESS_CODE it points the add at a closed
|
|
# port on this machine and proves the failure path across the real wire: a
|
|
# useful error comes back, no printer row is created, no store entry is made,
|
|
# and the proxy's log never contains the access code.
|
|
#
|
|
# WITH them (a printer you are willing to connect to - this only READS, it
|
|
# sends no command) it proves the success path as well: 201, telemetry flows
|
|
# under the new id, the proxy's store file is owner-only, and after the proxy
|
|
# is restarted with NO printers file the printer comes back from the store.
|
|
# REAL_HOST=192.168.0.20 REAL_SERIAL=... REAL_ACCESS_CODE=... scripts/e2e-provision-proxy.sh
|
|
#
|
|
# 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:-3034}"
|
|
AUTH_PORT="${AUTH_PORT:-3035}"
|
|
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=""
|
|
PROXY_PID=""
|
|
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
|
|
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")
|
|
[ -n "$PROXY_PID" ] && pids+=("$PROXY_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 "--- proxy log (tail, credentials are never logged) ---"; 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: $*"; }
|
|
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 and a real gateway ----------------------------------------
|
|
|
|
OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")"
|
|
expect 201 POST /orgs "$OWNER" '{"name":"e2e provision-proxy org"}' -- "create an org"
|
|
ORG_A="$(json 'd["id"]')"
|
|
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e provision-proxy farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm"
|
|
FARM_A="$(json 'd["id"]')"
|
|
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"]')"
|
|
|
|
echo "==> building and starting the real continuum-proxy with NO printers"
|
|
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
|
|
STORE="$WORK/proxy-store/continuum.db"
|
|
: > "$WORK/proxy.log"
|
|
start_proxy() {
|
|
# env -C so $! is the daemon itself and cleanup does not orphan it.
|
|
env -C "$PROXY_DIR" \
|
|
CONTINUUM_GATEWAY_ID=e2e-gw \
|
|
CONTINUUM_FARM_ID="$FARM_A" \
|
|
CONTINUUM_GATEWAY_TOKEN="$PROXY_TOKEN" \
|
|
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
|
|
CONTINUUM_GO2RTC_BIN=/nonexistent-go2rtc \
|
|
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
|
CONTINUUM_PRINTERS_PATH="$WORK/no-such-printers.toml" \
|
|
CONTINUUM_STORE_PATH="$STORE" \
|
|
RUST_LOG=info \
|
|
"$PROXY_DIR/target/debug/continuum-proxy" >> "$WORK/proxy.log" 2>&1 &
|
|
PROXY_PID=$!
|
|
}
|
|
stop_proxy() {
|
|
[ -n "$PROXY_PID" ] && kill "$PROXY_PID" 2>/dev/null || true
|
|
sleep 1
|
|
[ -n "$PROXY_PID" ] && kill -9 "$PROXY_PID" 2>/dev/null || true
|
|
PROXY_PID=""
|
|
}
|
|
wait_for_gateway() {
|
|
for _ in $(seq 1 60); do
|
|
grep -q "gateway e2e-gw connected" "$WORK/backend.log" && return 0
|
|
sleep 0.5
|
|
done
|
|
return 1
|
|
}
|
|
start_proxy
|
|
wait_for_gateway || fail "the proxy did not connect to the backend: $(tail -5 "$WORK/proxy.log")"
|
|
ok "the proxy connected with no printers file at all, from its store alone"
|
|
[ -f "$STORE" ] && ok "and created its store at $STORE" || bad "no store file was created"
|
|
|
|
# --- 3. the failure path across the real wire ------------------------------
|
|
|
|
echo "==> adding a printer that cannot be reached"
|
|
DEAD_CODE="DEADCODE-$(new_uuid | cut -c1-8)"
|
|
expect 502 POST "/farms/$FARM_A/printers" "$OWNER" \
|
|
"{\"name\":\"Nowhere\",\"vendor\":\"bambu\",\"host\":\"127.0.0.1\",\"accessCode\":\"$DEAD_CODE\",\"serialNumber\":\"01P00A000000099\"}" \
|
|
-- "the proxy reports it could not connect"
|
|
body_has GATEWAY_REFUSED
|
|
body_has "could not connect"
|
|
body_lacks "$DEAD_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 failed add: $N"
|
|
grep -q "$DEAD_CODE" "$WORK/proxy.log" "$WORK/backend.log" && bad "the access code is in a log" || ok "the code is in neither the proxy's nor the backend's log"
|
|
if command -v sqlite3 >/dev/null 2>&1; then
|
|
[ "$(sqlite3 "$STORE" 'select count(*) from printers')" = "0" ] && ok "nothing was stored on the gateway" || bad "the proxy stored a printer it could not reach"
|
|
fi
|
|
|
|
# --- 4. the success path, only with a printer you name ---------------------
|
|
|
|
if [ -n "${REAL_HOST:-}" ] && [ -n "${REAL_SERIAL:-}" ] && [ -n "${REAL_ACCESS_CODE:-}" ]; then
|
|
echo "==> adding the real printer at $REAL_HOST (read-only: connect and listen)"
|
|
expect 201 POST "/farms/$FARM_A/printers" "$OWNER" \
|
|
"{\"name\":\"Real printer\",\"vendor\":\"bambu\",\"host\":\"$REAL_HOST\",\"accessCode\":\"$REAL_ACCESS_CODE\",\"serialNumber\":\"$REAL_SERIAL\"}" \
|
|
-- "the proxy connected to the printer and the cloud created the row"
|
|
PRINTER_ID="$(json 'd["id"]')"
|
|
body_lacks "$REAL_ACCESS_CODE"
|
|
MODE="$(stat -c %a "$STORE")"
|
|
[ "$MODE" = "600" ] && ok "the store file is owner-only (600)" || bad "the store file mode is $MODE"
|
|
|
|
wait_telemetry() {
|
|
for _ in $(seq 1 40); do
|
|
[ -n "$(psql_q "SELECT vendor_state FROM printers WHERE id='$PRINTER_ID' AND vendor_state IS NOT NULL")" ] && return 0
|
|
sleep 0.5
|
|
done
|
|
return 1
|
|
}
|
|
wait_telemetry && ok "telemetry arrives under the new id" || bad "no telemetry for the added printer"
|
|
|
|
echo "==> restart the proxy with no printers file: the printer must come back from the store"
|
|
psql_q "UPDATE printers SET vendor_state=NULL, last_seen_at=NULL WHERE id='$PRINTER_ID'" >/dev/null
|
|
stop_proxy
|
|
# The edge JWT is short-lived but outlives this test.
|
|
start_proxy
|
|
wait_telemetry && ok "after a restart the printer reports again, from the store alone" || bad "the printer did not come back after a restart"
|
|
grep -q "$REAL_ACCESS_CODE" "$WORK/proxy.log" "$WORK/backend.log" && bad "the access code is in a log" || ok "the access code is in no log"
|
|
else
|
|
echo " (skipping the success path: set REAL_HOST, REAL_SERIAL and REAL_ACCESS_CODE to run it against a real printer)"
|
|
fi
|
|
|
|
echo
|
|
if [ "$FAILURES" -eq 0 ]; then
|
|
echo "PASS - the real proxy took provisioning requests over the real wire."
|
|
else
|
|
echo "FAIL - $FAILURES assertion(s) failed." >&2
|
|
exit 1
|
|
fi
|