Phase 1 — multi-user auth: - server/auth.py: bcrypt passwords, itsdangerous signed-cookie sessions, sha256 bearer tokens, FastAPI current_user/require_owner deps, login rate limit - users + api_tokens tables + jobs/assets.user_id (additive migrations) - HARD RULE enforced server-side: guests are local-only — /api/operators filters out requires_env operators, POST /api/jobs 403s cloud ops for non-owners (proven via direct POST in smoke.sh, not just UI). Settings owner-only. auth_secret hidden from the settings API. Per-user active-job cap (owner exempt). Own-asset/ own-job checks. WS auth via cookie or ?token=. Owner bootstrap prints pw once. - mb-ready: bearer MB_TOKEN; scripts/users.py for out-of-band management - Frontend: Login gate, header user chip + logout, guest note, username on jobs, Users panel in Settings (owner) Phase 2 — dashboard: - server/sysinfo.py: psutil CPU/RAM/disk + macmon Apple GPU (util/power/temp, no sudo), computed lane occupancy, 24h job summary, recent jobs w/ output thumbs; all cached (5s stats, 5min du). /api/system + /api/jobs/recent. - Dashboard.jsx: snapshot-on-refresh (no polling) — stat cards, per-core strip, lane strip, running/queued, recent grid. tests/smoke.sh rewritten for auth: 28 checks passing incl. all guest-security rules. Browser-verified owner + guest + dashboard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
114 lines
7.6 KiB
Bash
Executable File
114 lines
7.6 KiB
Bash
Executable File
#!/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 CAN run a local operator
|
|
FFPAY='{"operator":"ffprobe","asset_id":"'"$VID"'"}'
|
|
c=$(code -b "$GJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$FFPAY")
|
|
[ "$c" = 200 ] && ok "guest can run local ffprobe" || bad "guest local blocked ($c)"
|
|
# guest cannot delete owner's asset
|
|
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)"
|
|
# 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 "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"
|
|
c=$(code -b "$GJAR" "$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 ]
|