#!/usr/bin/env bash # Onboarding end to end, against the real routes and database: inviting # someone by email, and connecting a gateway with a one-time code instead of # copying a token. With the proxy built, the last step runs the real # `continuum-proxy enrol` against the live backend and starts the proxy on # what it wrote. # # Nothing here sends an email: the invitation link is returned to the inviter # (see the invitations route). # # scripts/dev-env.sh up && scripts/dev-env.sh schema # scripts/e2e-onboarding.sh set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" PORT="${PORT:-3064}"; AUTH_PORT="${AUTH_PORT:-3065}" BASE="http://localhost:$PORT" PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}" 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}" export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT export 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}" WORK="$(mktemp -d)" BACKEND_PID=""; AUTH_PID=""; PROXY_PID=""; ORG_ID=""; FAILURES=0 psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; } new_uuid() { cat /proc/sys/kernel/random/uuid; } fail() { echo "FAIL: $*" >&2; exit 1; } ok() { echo " ok: $*"; } bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); } cleanup() { local rc=$? for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done sleep 0.5 for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done [ -z "${KEEP_DATA:-}" ] && [ -n "$ORG_ID" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true if [ $rc -ne 0 ]; then echo "--- backend log (tail) ---"; tail -20 "$WORK/backend.log" 2>/dev/null || true; fi rm -rf "$WORK"; exit $rc } trap cleanup EXIT req() { # method path token [json] -> status; body in $WORK/last.json local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$1" "$BASE$2") [ "$3" != "-" ] && args+=(-H "authorization: Bearer $3") [ -n "${4:-}" ] && args+=(-H 'content-type: application/json' -d "$4") curl "${args[@]}" } expect() { # code method path token [body] -- what local want="$1" method="$2" path="$3" token="$4"; shift 4 local body=""; [ "${1:-}" != "--" ] && { body="$1"; shift; }; shift local got; got="$(req "$method" "$path" "$token" "$body")" [ "$got" = "$want" ] && ok "$* ($got)" || bad "$*: expected $want, got $got - $(head -c 250 "$WORK/last.json")" } json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; } 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 export SUPABASE_URL="$AUTH_URL" mint() { curl -sf "$AUTH_URL/token?sub=$1&email=$2" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; } 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 || fail "backend did not become healthy" OWNER_ID="$(new_uuid)"; GUEST_ID="$(new_uuid)"; STRANGER_ID="$(new_uuid)" TAG="$(new_uuid | cut -c1-8)" OWNER="$(mint "$OWNER_ID" "owner-$TAG@example.test")" GUEST="$(mint "$GUEST_ID" "Guest-$TAG@Example.Test")" STRANGER="$(mint "$STRANGER_ID" "stranger-$TAG@example.test")" echo "==> an organisation and a farm" expect 201 POST /orgs "$OWNER" '{"name":"e2e onboarding org"}' -- "owner creates an org" ORG_ID="$(json 'd["id"]')" expect 201 POST /farms "$OWNER" "{\"name\":\"e2e onboarding farm\",\"orgId\":\"$ORG_ID\"}" -- "owner creates a farm" FARM_ID="$(json 'd["id"]')" echo "==> inviting by email" expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"guest-$TAG@example.test\",\"role\":\"operator\"}" -- "owner invites an address" TOKEN="$(json 'd["token"]')"; INV_ID="$(json 'd["id"]')" grep -q "$TOKEN" <<<"$(psql_q "SELECT token_hash FROM org_invitations WHERE id='$INV_ID'")" && bad "the token is stored in the clear" || ok "only a hash of the token is stored" expect 404 POST "/orgs/$ORG_ID/invitations" "$GUEST" "{\"email\":\"x-$TAG@example.test\",\"role\":\"viewer\"}" -- "a non-member cannot invite (the org does not exist for them)" expect 200 GET "/orgs/$ORG_ID/invitations" "$OWNER" -- "owner lists pending invitations" [ "$(json 'len(d)')" = 1 ] && ok "one pending" || bad "pending list: $(cat "$WORK/last.json")" grep -q "$TOKEN" "$WORK/last.json" && bad "the list leaks the token" || ok "the list does not show the token" echo "==> accepting" expect 403 POST /invitations/accept "$STRANGER" "{\"token\":\"$TOKEN\"}" -- "someone signed in with another address cannot use the link" expect 404 POST /invitations/accept "$GUEST" '{"token":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}' -- "a made-up token is refused" expect 200 POST /invitations/accept "$GUEST" "{\"token\":\"$TOKEN\"}" -- "the invited address accepts (case-insensitively)" [ "$(json 'd["role"]')" = operator ] && ok "joined as operator" || bad "role: $(cat "$WORK/last.json")" expect 404 POST /invitations/accept "$GUEST" "{\"token\":\"$TOKEN\"}" -- "the link works once" expect 200 GET "/orgs/$ORG_ID/members" "$OWNER" -- "owner lists members" grep -q "guest-$TAG@example.test" "$WORK/last.json" && ok "the member list shows the email" || bad "no email in $(cat "$WORK/last.json")" expect 200 GET "/orgs/$ORG_ID" "$GUEST" -- "the new member can see the org" echo "==> expiry and revocation" expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"stranger-$TAG@example.test\",\"role\":\"viewer\"}" -- "a second invitation" T2="$(json 'd["token"]')"; I2="$(json 'd["id"]')" psql_q "UPDATE org_invitations SET expires_at = now() - interval '1 minute' WHERE id='$I2'" >/dev/null expect 404 POST /invitations/accept "$STRANGER" "{\"token\":\"$T2\"}" -- "an expired invitation is refused" expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"stranger-$TAG@example.test\",\"role\":\"viewer\"}" -- "re-inviting issues a fresh one" T3="$(json 'd["token"]')"; I3="$(json 'd["id"]')" expect 200 DELETE "/orgs/$ORG_ID/invitations/$I3" "$OWNER" -- "owner revokes it" expect 404 POST /invitations/accept "$STRANGER" "{\"token\":\"$T3\"}" -- "a revoked invitation is refused" echo "==> enrolling a gateway with a one-time code" expect 403 POST "/farms/$FARM_ID/enrolment-codes" "$GUEST" -- "an operator cannot make an enrolment code" expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "owner makes a code" CODE="$(json 'd["code"]')" [ "$(psql_q "SELECT count(*) FROM enrolment_codes WHERE code_hash = '$CODE'")" = 0 ] && ok "the code is not stored in the clear" || bad "code stored in the clear" TYPED="$(echo "$CODE" | tr 'A-Z' 'a-z' | tr -d '-')" expect 200 POST /auth/enrol - "{\"code\":\"$TYPED\"}" -- "the gateway trades the code (typed sloppily)" GW_TOKEN="$(json 'd["gatewayToken"]')" [ "$(json 'd["farmId"]')" = "$FARM_ID" ] && ok "it is told which farm" || bad "farm: $(cat "$WORK/last.json")" expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_ID\",\"edgeProxyToken\":\"$GW_TOKEN\"}" -- "the credential it got works" expect 401 POST /auth/enrol - "{\"code\":\"$CODE\"}" -- "a code works once" expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a second code" C2="$(json 'd["code"]')" expect 200 POST /auth/enrol - "{\"code\":\"$C2\"}" -- "enrolling a second machine" [ "$(json 'd["gatewayToken"]')" = "$GW_TOKEN" ] && ok "the farm's token was not rotated, so the first gateway still works" || bad "the token changed" expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a third code, to expire" C3="$(json 'd["code"]')" psql_q "UPDATE enrolment_codes SET expires_at = now() - interval '1 minute' WHERE farm_id='$FARM_ID' AND used_at IS NULL" >/dev/null expect 401 POST /auth/enrol - "{\"code\":\"$C3\"}" -- "an expired code is refused" if [ -x "$PROXY_DIR/target/debug/continuum-proxy" ] && "$PROXY_DIR/target/debug/continuum-proxy" enrol --help >/dev/null 2>&1; then echo "==> the real proxy enrols itself" expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a code for the proxy" PCODE="$(json 'd["code"]')" CONF="$WORK/proxy.toml" ( cd "$WORK" && CONTINUUM_CONFIG_PATH="$CONF" "$PROXY_DIR/target/debug/continuum-proxy" enrol "$BASE" "$PCODE" > "$WORK/enrol.log" 2>&1 ) || { cat "$WORK/enrol.log"; fail "proxy enrol failed"; } [ "$(stat -c %a "$CONF")" = 600 ] && ok "the config it wrote is owner-only" || bad "config mode $(stat -c %a "$CONF")" grep -q "$PCODE" "$WORK/enrol.log" && bad "the code is in the proxy output" || ok "the code is not echoed" grep -q "gateway_token" "$CONF" && grep -q "farm_id = \"$FARM_ID\"" "$CONF" && ok "farm and token are in the config" || bad "config: $(sed 's/gateway_token.*/gateway_token = /' "$CONF")" env -C "$PROXY_DIR" CONTINUUM_CONFIG_PATH="$CONF" CONTINUUM_GO2RTC_BIN=/nonexistent CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \ CONTINUUM_PRINTERS_PATH="$WORK/none.toml" CONTINUUM_STORE_PATH="$WORK/store/continuum.db" RUST_LOG=info \ "$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 & PROXY_PID=$! for _ in $(seq 1 40); do grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && break; sleep 0.5; done grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && ok "it started on that config alone and the cloud accepted it" || bad "the enrolled proxy never connected: $(tail -5 "$WORK/proxy.log")" else echo " (skipping the proxy step: build continuum-proxy with the enrol command first)" fi # Last, because it blocks this address from enrolling for ten minutes. echo "==> guessing" for i in $(seq 1 12); do req POST /auth/enrol - '{"code":"ZZZZZ-ZZZZZ"}' >/dev/null; done expect 429 POST /auth/enrol - '{"code":"ZZZZZ-ZZZZZ"}' -- "guessing is rate-limited" echo [ "$FAILURES" -eq 0 ] && echo "PASS - invitations and one-time-code enrolment." || { echo "FAIL - $FAILURES assertion(s)." >&2; exit 1; }