Until now every HTTP route was unauthenticated. A probe against the
running server returned a farm's `edgeProxyToken` and a printer's
`accessCode` to an anonymous caller, and that pair is enough to connect
as a farm's gateway and drive its printers. The previous commit stopped
the credentials appearing in responses; this one makes the routes ask
who is calling.
Authentication. Access tokens are verified against the Supabase
project's published JWKS via `createSupabaseJwtVerifier` from
`@continuum/ts-core`: RS256, public key only, no secret in this
process. It replaces the HS256 `@elysiajs/jwt` path, where
`SUPABASE_JWT_SECRET` was simultaneously the verification key and a
key that could mint tokens, present in the environment of every
instance. `src/auth/verifier.ts` builds it at module load so a
misconfigured deployment fails at boot rather than on first login.
The edge-gateway token stays symmetric and that is not an
inconsistency: this service is both its issuer and its only verifier,
so there is no second party to keep a secret from. It now carries an
`exp`, which it did not, despite the response saying
`expiresInSeconds: 3600`.
Tenancy. An org layer above farms - `organisations`,
`org_members(org_id, user_id, role)`, `farms.org_id` - so a farm can
have more than one operator without sharing an account. `farms.owner_id`
is gone. Authorization is one rule applied everywhere: resolve the
thing being acted on back to an organisation and check the caller's
role there, user -> org -> farm -> printer, with nothing in between
carrying permissions of its own. The ladder is
`viewer` < `operator` < `owner`; printer commands and job submission
need `operator`, because they move physical machines, and that is the
one boundary here that is not about data.
A non-member gets 404, not 403: a 403 would confirm that a given farm
or printer id exists and belongs to someone, which is an enumeration
oracle over the whole system. 403 is kept for a member who may see the
record but not do this to it.
List endpoints scope in the `where` clause, not after the query - a
filter applied to the result is one `return` away from being skipped.
They are also bounded now (`limit`/`offset`, default 50, max 200
refused rather than clamped).
`ownerId` and `submittedBy` no longer come from request bodies. Nor do
the printer telemetry columns: `POST /printers` is narrowed to the
columns a client owns, because a client that could POST
`status: "idle"` could make the scheduler send work to a printer that
is mid-print.
Also fills the CRUD gaps that made the API unusable - PATCH and DELETE
for farms, printers, jobs and orgs, and membership management - plus
one error shape for the whole API via `toErrorResponse`, so an
authorization failure is a throw that leaves the handler rather than a
status somebody has to remember to set.
Guards on the two changes that cannot be undone through the API:
removing or demoting an organisation's last owner, and deleting an
organisation while farms are still attached (it would cascade to every
printer, print job and stored frame).
Migration 0001 is hand-edited after generation. drizzle-kit emitted
`ADD COLUMN org_id uuid NOT NULL`, which cannot apply to a populated
table, and dropped `owner_id` without moving what it encodes anywhere.
It now adds the column nullable, creates one organisation per distinct
legacy owner with that user as its `owner` member, points each farm at
it, and only then tightens the column and drops `owner_id`. Verified
both ways: from empty via `drizzle-kit migrate`, and against a seeded
database where two farms shared an owner and a third did not.
Testing. `scripts/dev-auth.ts` is a local RS256 issuer publishing a
JWKS at the path the backend fetches, so the real verification path is
testable without a Supabase project. The alternative was a test-only
bypass in the backend - an env var making it trust an unsigned token -
which would be a permanent hole one misconfiguration from production,
and would mean the code under test is not the code that ships.
- `tests/authz.test.ts` (19 tests): the role ladder written out in full
rather than derived the same way the implementation derives it, and
the real JWKS path - a token signed by a key outside the JWKS, an
expired token and a token with `aud: "anon"` (Supabase's own anon key
is a valid project JWT) are all rejected, with the same message, so
probing cannot tell which part of a forged token to fix next.
- `scripts/e2e-authz.sh` (68 assertions, real Postgres, real routes):
four users across two organisations; anonymous 401s, cross-org 404s,
viewer-cannot-command, operator-cannot-rotate, a spoofed
`submittedBy` in the body ignored, no credential in any read, and the
rotated token still minting an edge JWT.
- `scripts/e2e-edge.sh` updated for the org layer and now authenticates
as a seeded `operator` - the weakest role that should work, so the
run would fail if the command boundary were not where it claims to
be. Re-verified against both live printers: telemetry from `h2c` and
`p1p` persisted, and an authenticated `set_fan_speed` went
HTTP -> WebSocket -> MQTT -> P1P and back `ok:true`, fan returned
to 0.
Two bugs found in the test scripts while writing this, both the same
shape as the earlier `pkill -f` incident: `kill "${PID:-0}"` with an
unset PID signals the whole process group, including the harness, and
`uuidgen` is not installed in this sandbox.
324 lines
15 KiB
Bash
Executable File
324 lines
15 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
|
|
}
|
|
|
|
psql_q() { PGPASSWORD=continuum psql -h localhost -U continuum -d continuum -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"
|
|
|
|
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 with an access code"
|
|
PRINTER_A="$(json 'd["id"]')"
|
|
body_lacks SECRET123
|
|
ok "printer A = $PRINTER_A, and the create response carries no access code"
|
|
|
|
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"
|
|
|
|
# --- 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
|