modelbeast/tests/smoke.sh
MODELBEAST 810400fb60 Auth + dashboard (HANDOFF2 phases 1-2): guests local-only, system snapshot
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>
2026-07-13 12:06:52 +10:00

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 ]