myanime-backend/scripts/verify.py
Claude 24d59c3f91 feat: PocketBase backend + account/profile schema (epic #6, issue #9)
Foundation for the user-accounts platform. Self-hosted PocketBase v0.39.6,
version-pinned from the official release binary, schema-as-code so the
collections auto-apply on boot and never drift from hand-clicking.

Collections: users (auth, admin-only nsfwEnabled), profiles, watch_state,
watchlist, prefs. Per-owner access rules traverse profile.user; nsfwEnabled
is never client-writable (superuser-only). Relations cascadeDelete; unique
indexes keep one resume/watchlist row per (profile,itemId) and one prefs per
profile.

Verified locally against v0.39.6: migration applies clean, and scripts/verify.py
proves two users can't read/write each other's data and can't set their own
nsfwEnabled (19/19 checks). Coolify deploy (domain/TLS, superuser, SMTP,
backups) documented in README as the manual half.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:22:45 +02:00

125 lines
5.6 KiB
Python

"""End-to-end access-rule check for the myanime PocketBase schema.
Run against a local `docker compose up --build` instance that has a superuser.
Overridable via env: PB_BASE, PB_ADMIN_EMAIL, PB_ADMIN_PASS.
docker compose exec pocketbase pocketbase superuser upsert \\
admin@myanime.local 'Sup3rSecret!123' --dir=/pb_data
python scripts/verify.py
Creates test users/profiles/rows, then asserts users can't see or touch each
other's data and can't set their own admin-only `nsfwEnabled`. Exits non-zero
if any check fails.
"""
import json, os, sys, urllib.request, urllib.error
BASE = os.environ.get("PB_BASE", "http://localhost:8090")
ADMIN_EMAIL = os.environ.get("PB_ADMIN_EMAIL", "admin@myanime.local")
ADMIN_PASS = os.environ.get("PB_ADMIN_PASS", "Sup3rSecret!123")
def req(method, path, token=None, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(BASE + path, data=data, method=method)
r.add_header("Content-Type", "application/json")
if token:
r.add_header("Authorization", token)
try:
with urllib.request.urlopen(r) as resp:
return resp.status, json.loads(resp.read() or "{}")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or "{}")
def show(label, status, ok):
mark = "PASS" if ok else "FAIL"
print(f"[{mark}] {label} -> HTTP {status}")
return ok
allpass = True
def check(label, status, ok):
global allpass
allpass = show(label, status, ok) and allpass
# --- superuser ---
st, r = req("POST", "/api/collections/_superusers/auth-with-password",
body={"identity": ADMIN_EMAIL, "password": ADMIN_PASS})
admin = r["token"]
# --- schema assertions ---
st, users = req("GET", "/api/collections/users", admin)
fnames = {f["name"] for f in users["fields"]}
check("users has username + nsfwEnabled", st, {"username", "nsfwEnabled"} <= fnames)
check("users.createRule blocks nsfwEnabled",
st, "nsfwEnabled:isset = false" in (users["createRule"] or ""))
check("users.updateRule blocks nsfwEnabled",
st, "nsfwEnabled:isset = false" in (users["updateRule"] or ""))
for name in ("profiles", "watch_state", "watchlist", "prefs"):
st, c = req("GET", f"/api/collections/{name}", admin)
rules_owned = all("@request.auth.id" in (c[k] or "") for k in
("listRule","viewRule","createRule","updateRule","deleteRule"))
check(f"{name} exists with per-owner rules on all 5 verbs", st, rules_owned)
# unique indexes present
st, ws = req("GET", "/api/collections/watch_state", admin)
check("watch_state has unique (profile,itemId) index", st,
any("idx_watch_state_profile_item" in i for i in ws["indexes"]))
# --- create two users (superuser create; nsfwEnabled set by admin) ---
def make_user(email, uname):
st, r = req("POST", "/api/collections/users/records", admin, {
"email": email, "password": "Passw0rd!23", "passwordConfirm": "Passw0rd!23",
"username": uname, "nsfwEnabled": True,
})
return st, r
st, ua = make_user("alice@example.com", "alice")
check("superuser CAN create user + set nsfwEnabled", st, st == 200 and ua.get("nsfwEnabled") is True)
st, ub = make_user("bob@example.com", "bob")
check("second user created", st, st == 200)
def login(email):
st, r = req("POST", "/api/collections/users/auth-with-password", body={
"identity": email, "password": "Passw0rd!23"})
return r.get("token")
alice = login("alice@example.com")
bob = login("bob@example.com")
# --- alice creates a profile + rows ---
st, pa = req("POST", "/api/collections/profiles/records", alice,
{"user": ua["id"], "name": "Alice Main"})
check("alice creates her own profile", st, st == 200)
st, wsrow = req("POST", "/api/collections/watch_state/records", alice,
{"profile": pa["id"], "itemId": "tt0111161", "itemType": "movie",
"position": 120, "duration": 8520})
check("alice creates watch_state under her profile", st, st == 200)
# --- ISOLATION: bob must not see or touch alice's data ---
st, r = req("GET", f"/api/collections/profiles/records/{pa['id']}", bob)
check("bob CANNOT view alice's profile (404)", st, st == 404)
st, r = req("GET", "/api/collections/watch_state/records", bob)
check("bob's watch_state list is empty", st, st == 200 and r.get("totalItems") == 0)
st, r = req("PATCH", f"/api/collections/watch_state/records/{wsrow['id']}", bob,
{"position": 999})
check("bob CANNOT update alice's watch_state (404)", st, st == 404)
# --- bob cannot create a profile owned by alice ---
st, r = req("POST", "/api/collections/profiles/records", bob,
{"user": ua["id"], "name": "sneaky"})
check("bob CANNOT create a profile owned by alice", st, st in (400, 403, 404))
# --- alice cannot flip her own nsfwEnabled ---
st, r = req("PATCH", f"/api/collections/users/records/{ua['id']}", alice,
{"nsfwEnabled": False})
check("alice CANNOT change her nsfwEnabled (rule blocks)", st, st in (400, 403, 404))
st, me = req("GET", f"/api/collections/users/records/{ua['id']}", alice)
check("alice's nsfwEnabled still True after attempt", st, me.get("nsfwEnabled") is True)
# --- alice cannot create watch_state under bob's (nonexistent-to-her) profile ---
st, pb = req("POST", "/api/collections/profiles/records", bob, {"user": ub["id"], "name": "Bob Main"})
st, r = req("POST", "/api/collections/watch_state/records", alice,
{"profile": pb["id"], "itemId": "tt0068646", "itemType": "movie"})
check("alice CANNOT attach a row to bob's profile", st, st in (400, 403, 404))
print("\n" + ("ALL CHECKS PASSED" if allpass else "SOME CHECKS FAILED"))
sys.exit(0 if allpass else 1)