#!/usr/bin/env bash # End-to-end smoke test against a throwaway data dir on a scratch port. # Validates: auth (401/login/token), operators/settings, the 3 core operators, # retry/delete, and the GUEST security rules (local-only, owner-only settings, # per-user cap, own-asset). Never touches the production DB/server. set -euo pipefail cd "$(dirname "$0")/.." PORT=${PORT:-8799} TMP=$(mktemp -d) BLENDER="/Applications/Blender.app/Contents/MacOS/Blender" UV=/opt/homebrew/bin/uv OJAR="$TMP/owner.cj"; GJAR="$TMP/guest.cj"; CJAR="$TMP/capped.cj" pass=0; fail=0 say() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; } ok() { printf " \033[32mPASS\033[0m %s\n" "$1"; pass=$((pass+1)); } bad() { printf " \033[31mFAIL\033[0m %s\n" "$1"; fail=$((fail+1)); } j() { python3 -c "import json,sys;d=json.load(sys.stdin);print($1)"; } code(){ curl -s -o /dev/null -w "%{http_code}" "$@"; } # print HTTP status cleanup() { kill "${SRV:-0}" 2>/dev/null || true; rm -rf "$TMP"; } trap cleanup EXIT say "fixtures" ffmpeg -y -f lavfi -i "testsrc2=size=640x360:rate=30:duration=3" -pix_fmt yuv420p "$TMP/clip.mp4" >/dev/null 2>&1 ok "test video" "$BLENDER" -b --python-expr " import bpy bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.mesh.primitive_monkey_add() bpy.ops.export_scene.gltf(filepath='$TMP/mesh.glb', export_format='GLB')" >/dev/null 2>&1 ok "test glb" say "start server (MODELBEAST_DATA=$TMP/data, port $PORT)" MODELBEAST_DATA="$TMP/data" "$UV" run uvicorn server.main:app --host 127.0.0.1 --port "$PORT" >"$TMP/server.log" 2>&1 & SRV=$! for i in $(seq 1 40); do curl -s "http://127.0.0.1:$PORT/api/me" >/dev/null 2>&1 && break; sleep 0.5; done sleep 1 B="http://127.0.0.1:$PORT" OWNERPW=$(grep "password:" "$TMP/server.log" | head -1 | awk '{print $2}') say "auth" [ "$(code $B/api/operators)" = 401 ] && ok "unauth API → 401" || bad "unauth not 401" LOGIN=$(code -c "$OJAR" -X POST $B/api/login -H 'Content-Type: application/json' -d "{\"username\":\"monster\",\"password\":\"$OWNERPW\"}") [ "$LOGIN" = 200 ] && ok "owner login" || bad "owner login ($LOGIN)" [ "$(curl -s -b "$OJAR" $B/api/me | j "d['role']")" = owner ] && ok "/api/me = owner" || bad "me" [ "$(code -X POST $B/api/login -H 'Content-Type: application/json' -d '{"username":"monster","password":"wrong"}')" = 401 ] && ok "bad password → 401" || bad "bad pw" # token flow TOK=$(curl -s -b "$OJAR" -X POST $B/api/tokens -H 'Content-Type: application/json' -d '{"name":"test"}' | j "d['token']") [ "$(code -H "Authorization: Bearer $TOK" $B/api/operators)" = 200 ] && ok "bearer token works" || bad "token" [ "$(code -H "Authorization: Bearer mbt_bogus" $B/api/operators)" = 401 ] && ok "bogus token → 401" || bad "bogus token" say "operators + settings (owner)" NOPS=$(curl -s -b "$OJAR" "$B/api/operators" | j "len(d)") [ "$NOPS" -ge 3 ] && ok "operators loaded ($NOPS)" || bad "operators ($NOPS)" curl -s -b "$OJAR" -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"archive_path":"/tmp/xyz"}' >/dev/null [ "$(curl -s -b "$OJAR" "$B/api/settings" | j "d['archive_path']")" = "/tmp/xyz" ] && ok "settings persist" || bad "settings" curl -s -b "$OJAR" -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"fal_key":"sk-secret-123456"}' >/dev/null [ "$(curl -s -b "$OJAR" "$B/api/settings" | j "d['fal_key']")" = "••••••••" ] && ok "secret masked" || bad "secret leak" [ "$(curl -s -b "$OJAR" "$B/api/settings" | j "'auth_secret' in d")" = False ] && ok "auth_secret hidden" || bad "auth_secret LEAKED" say "core operators (owner)" VID=$(curl -s -b "$OJAR" -F "file=@$TMP/clip.mp4" "$B/api/assets" | j "d['id']") GLB=$(curl -s -b "$OJAR" -F "file=@$TMP/mesh.glb" "$B/api/assets" | j "d['id']") post_job() { curl -s -b "$OJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$1" | j "d['id']"; } wait_job() { local s=""; for i in $(seq 1 60); do s=$(curl -s -b "$OJAR" "$B/api/jobs/$1" | j "d.get('status','?')"); { [ "$s" = done ] || [ "$s" = error ]; } && break; sleep 0.5; done; echo "$s"; } JID=$(post_job '{"operator":"ffprobe","asset_id":"'"$VID"'"}'); S=$(wait_job "$JID") [ "$S" = done ] && ok "ffprobe" || bad "ffprobe ($S)" JID=$(post_job '{"operator":"ffmpeg_frames","asset_id":"'"$VID"'","params":{"fps":4,"max_frames":8}}'); S=$(wait_job "$JID") [ "$S" = done ] && ok "ffmpeg_frames" || bad "ffmpeg_frames ($S)" LASTFBX=$(post_job '{"operator":"blender_convert","asset_id":"'"$GLB"'","params":{"target":"fbx","apply_transforms":true}}'); S=$(wait_job "$LASTFBX") [ "$S" = done ] && ok "blender_convert" || bad "blender_convert ($S)" NFBX=$(curl -s -b "$OJAR" "$B/api/assets" | j "sum(1 for a in d if a['name'].endswith('.fbx'))") [ "$NFBX" -ge 1 ] && ok "fbx registered" || bad "no fbx" NEW=$(curl -s -b "$OJAR" -X POST "$B/api/jobs/$LASTFBX/retry" | j "d['id']") { [ -n "$NEW" ] && [ "$NEW" != "$LASTFBX" ]; } && ok "retry" || bad "retry" DC=$(code -b "$OJAR" -X DELETE "$B/api/jobs/$LASTFBX") [ "$DC" = 200 ] && ok "delete job" || bad "delete ($DC)" say "GUEST security (local-only hard rule)" curl -s -b "$OJAR" -X POST $B/api/users -H 'Content-Type: application/json' -d '{"username":"testmate","password":"guestpw1"}' >/dev/null curl -s -c "$GJAR" -X POST $B/api/login -H 'Content-Type: application/json' -d '{"username":"testmate","password":"guestpw1"}' >/dev/null GOPS=$(curl -s -b "$GJAR" "$B/api/operators" | j "len(d)") [ "$GOPS" -lt "$NOPS" ] && ok "guest sees fewer operators ($GOPS < $NOPS)" || bad "guest op count ($GOPS)" SEES=$(curl -s -b "$GJAR" "$B/api/operators" | j "any(o['id']=='fal_trellis' for o in d)") [ "$SEES" = False ] && ok "guest cannot see fal_trellis" || bad "guest sees cloud op" # the load-bearing test: direct POST of a cloud operator by a guest must 403 FALPAY='{"operator":"fal_trellis","asset_id":"'"$VID"'"}' c=$(code -b "$GJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$FALPAY") [ "$c" = 403 ] && ok "guest fal_trellis POST → 403 (server-side)" || bad "guest cloud NOT blocked ($c)" c=$(code -b "$GJAR" "$B/api/settings"); [ "$c" = 403 ] && ok "guest settings → 403" || bad "guest settings ($c)" c=$(code -b "$GJAR" -X POST "$B/api/users" -H 'Content-Type: application/json' -d '{"username":"x","password":"yyyyyy"}') [ "$c" = 403 ] && ok "guest cannot create users → 403" || bad "guest user-create ($c)" # guest uploads their OWN asset and runs a local op on it → 200 GVID=$(curl -s -b "$GJAR" -F "file=@$TMP/clip.mp4" "$B/api/assets" | j "d['id']") c=$(code -b "$GJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d '{"operator":"ffprobe","asset_id":"'"$GVID"'"}') [ "$c" = 200 ] && ok "guest runs local op on OWN asset" || bad "guest own-asset run ($c)" # per-user isolation: guest CANNOT use / see / delete / download the owner's asset FFPAY='{"operator":"ffprobe","asset_id":"'"$VID"'"}' c=$(code -b "$GJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$FFPAY") [ "$c" = 403 ] && ok "guest cannot use owner asset → 403" || bad "guest used owner asset ($c)" c=$(code -b "$GJAR" -X DELETE "$B/api/assets/$GLB"); [ "$c" = 403 ] && ok "guest cannot delete owner asset → 403" || bad "guest deleted owner asset ($c)" c=$(code -b "$GJAR" "$B/api/assets/$GLB/file"); [ "$c" = 403 ] && ok "guest cannot download owner asset → 403" || bad "guest downloaded owner asset ($c)" GSEE=$(curl -s -b "$GJAR" "$B/api/assets" | j "any(a['id']=='$GLB' for a in d)") [ "$GSEE" = False ] && ok "guest asset list excludes owner's" || bad "guest sees owner assets" # per-user cap: capped user with max_active_jobs=0 → any job 429 curl -s -b "$OJAR" -X POST "$B/api/users" -H 'Content-Type: application/json' -d '{"username":"capped","password":"cappedpw"}' >/dev/null curl -s -b "$OJAR" -X PATCH "$B/api/users/capped" -H 'Content-Type: application/json' -d '{"max_active_jobs":0}' >/dev/null curl -s -c "$CJAR" -X POST "$B/api/login" -H 'Content-Type: application/json' -d '{"username":"capped","password":"cappedpw"}' >/dev/null c=$(code -b "$CJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$FFPAY") [ "$c" = 429 ] && ok "per-user cap → 429" || bad "cap not enforced ($c)" say "hardening (from security review)" # upload filename path-traversal is sanitized to a basename TRAV=$(curl -s -b "$OJAR" -F "file=@$TMP/clip.mp4;filename=../../../evil.mp4" "$B/api/assets" | j "d['name']") [ "$TRAV" = "evil.mp4" ] && ok "upload filename sanitized to basename" || bad "filename traversal ($TRAV)" # password change revokes the existing session (session_epoch bump) curl -s -b "$OJAR" -X PATCH "$B/api/users/testmate" -H 'Content-Type: application/json' -d '{"password":"newpass123"}' >/dev/null c=$(code -b "$GJAR" "$B/api/me"); [ "$c" = 401 ] && ok "password change revokes old session" || bad "stale session still valid ($c)" # health endpoint is public (for serve.sh / proxy) c=$(code "$B/api/health"); [ "$c" = 200 ] && ok "/api/health public" || bad "health ($c)" say "dashboard" SHAPE=$(curl -s -b "$OJAR" "$B/api/system" | j "'gpu' in d and 'lanes' in d") [ "$SHAPE" = True ] && ok "/api/system shape" || bad "system shape" CJAR2="$TMP/mate2.cj" curl -s -c "$CJAR2" -X POST "$B/api/login" -H 'Content-Type: application/json' -d '{"username":"testmate","password":"newpass123"}' >/dev/null c=$(code -b "$CJAR2" "$B/api/system"); [ "$c" = 200 ] && ok "guest can view dashboard" || bad "guest dashboard ($c)" printf "\n\033[1mResult: %d passed, %d failed\033[0m\n" "$pass" "$fail" [ "$fail" -eq 0 ]