Invitations: token shown once, only its hash stored, 7-day expiry, the accepting account's email must match. Enrolment codes: 15 minutes, single use, hashed; POST /auth/enrol returns the farm's existing gateway token without rotating it and is rate-limited per IP. No email is sent; the inviter shares the link. Migration 0007, unit tests and scripts/e2e-onboarding.sh.
155 lines
10 KiB
Bash
Executable File
155 lines
10 KiB
Bash
Executable File
#!/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 = <hidden>/' "$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; }
|