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>
This commit is contained in:
commit
24d59c3f91
7 changed files with 458 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.git
|
||||
pb_data
|
||||
*.md
|
||||
docker-compose.yml
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# PocketBase runtime data (DB, uploaded files, logs) — never commit.
|
||||
pb_data/
|
||||
|
||||
# Local env / secrets
|
||||
.env
|
||||
.env.local
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# myanime-backend — PocketBase, version-pinned from the official release.
|
||||
#
|
||||
# We download the official static binary from GitHub releases (rather than a
|
||||
# third-party image) so the deploy is reproducible and easy to audit.
|
||||
FROM alpine:3.20 AS fetch
|
||||
|
||||
# Bump this to upgrade PocketBase. Keep it in sync with the README.
|
||||
ARG PB_VERSION=0.39.6
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
RUN apk add --no-cache ca-certificates unzip wget \
|
||||
&& wget -q "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${TARGETARCH}.zip" -O /tmp/pb.zip \
|
||||
&& unzip /tmp/pb.zip -d /pb \
|
||||
&& rm /tmp/pb.zip
|
||||
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& adduser -D -H -u 10001 pb
|
||||
|
||||
COPY --from=fetch /pb/pocketbase /usr/local/bin/pocketbase
|
||||
|
||||
# Schema-as-code: migrations are baked in and auto-applied on serve.
|
||||
COPY pb_migrations /pb_migrations
|
||||
|
||||
# Persist DB, uploaded files, and any admin-created migrations here. Create it
|
||||
# owned by the runtime user so a fresh named volume inherits writable perms.
|
||||
RUN mkdir -p /pb_data && chown pb /pb_data
|
||||
VOLUME ["/pb_data"]
|
||||
|
||||
EXPOSE 8090
|
||||
USER pb
|
||||
|
||||
# Coolify terminates TLS at Traefik; PocketBase listens plain on 8090 behind it.
|
||||
ENTRYPOINT ["pocketbase"]
|
||||
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations"]
|
||||
106
README.md
Normal file
106
README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# myanime-backend
|
||||
|
||||
**PocketBase** backend for the myanime app — auth, database, realtime, file
|
||||
storage and admin UI in a single Go binary. This is the foundation the account
|
||||
features build on: cloud sync, codeless device sign-in, profiles, child
|
||||
profiles, recommendations, and gated auto-update (epic
|
||||
[#6](https://projects.petruzalekr.cz/richiexec/myanime-app/issues/6)).
|
||||
|
||||
- **No E2E.** TLS in transit + at-rest on disk. The server is deliberately
|
||||
readable so recommendations, the admin `nsfwEnabled` flag, and painless
|
||||
password reset all work.
|
||||
- **Schema as code.** The collections live in [`pb_migrations/`](pb_migrations/)
|
||||
and auto-apply on boot — never hand-click the schema in the admin UI, or the
|
||||
next deploy will drift.
|
||||
- **Version-pinned.** The [`Dockerfile`](Dockerfile) downloads the official
|
||||
PocketBase **v0.39.6** static binary from GitHub releases (no third-party
|
||||
image). Bump `PB_VERSION` there and in `docker-compose.yml` to upgrade.
|
||||
|
||||
## Data model
|
||||
|
||||
```
|
||||
users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per profile+item)
|
||||
(auth)│ ├─* watchlist (1 row per profile+item)
|
||||
│ └─1 prefs (playback/audio/subtitle blob)
|
||||
└ nsfwEnabled (admin-only bool → gates adult vs clean build, issue #16)
|
||||
```
|
||||
|
||||
| Collection | Type | Key fields |
|
||||
|---------------|------|------------|
|
||||
| `users` | auth | `username` (unique when set), `email`, `password`, **`nsfwEnabled`** (admin-only) |
|
||||
| `profiles` | base | `user`→users, `name`, `avatar` (file), `isChild`, `pinHash` (hidden) |
|
||||
| `watch_state` | base | `profile`→profiles, `itemId`, `itemType`, `position`, `duration`, `updatedAt` |
|
||||
| `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `addedAt` |
|
||||
| `prefs` | base | `profile`→profiles, `data` (json) |
|
||||
|
||||
**Access rules.** A user only ever reads/writes their own `users` row, their own
|
||||
`profiles`, and rows whose `profile.user` is them. `nsfwEnabled` is never
|
||||
accepted from a client (`@request.body.nsfwEnabled:isset = false` on create and
|
||||
update) — only a **superuser** sets it, from the admin UI. Relations
|
||||
`cascadeDelete`, so deleting a user removes their profiles and all child rows.
|
||||
Unique indexes keep one resume/watchlist row per `(profile, itemId)` and one
|
||||
`prefs` row per profile.
|
||||
|
||||
## Local run / verify
|
||||
|
||||
```bash
|
||||
docker compose up --build # http://localhost:8090
|
||||
# create the first superuser (one-time):
|
||||
docker compose exec pocketbase pocketbase superuser upsert you@example.com 'a-strong-pass' --dir=/pb_data
|
||||
# admin UI: http://localhost:8090/_/
|
||||
```
|
||||
|
||||
The end-to-end access-rule check (two users can't see each other's data; a user
|
||||
can't set their own `nsfwEnabled`; a superuser can) lives in
|
||||
[`scripts/verify.py`](scripts/verify.py):
|
||||
|
||||
```bash
|
||||
python scripts/verify.py # expects the server on :8090 + the superuser above
|
||||
```
|
||||
|
||||
## Deploy on Coolify
|
||||
|
||||
The schema and image are automated; the steps below need your hands, Coolify
|
||||
access and secrets (which never live in this repo).
|
||||
|
||||
1. **Push this repo** to a remote Coolify can reach (e.g. GitHub, like
|
||||
`myanime-pair-server`).
|
||||
2. **New Resource → Dockerfile application**, point it at this repo. It listens
|
||||
on `8090`.
|
||||
3. **Persistent storage:** add a volume mounted at **`/pb_data`** (DB + uploaded
|
||||
files). Without this you lose data on redeploy.
|
||||
4. **Domain + TLS:** set the domain to `pb.petruzalekr.cz`; Coolify/Traefik
|
||||
provisions the Let's Encrypt cert and terminates TLS in front of the plain
|
||||
`:8090`. Force-HTTPS on.
|
||||
5. **Bootstrap the superuser** once, from the container terminal:
|
||||
`pocketbase superuser upsert you@example.com 'a-strong-pass' --dir=/pb_data`
|
||||
(or open the printed `/_/#/pbinstall/...` link on first boot). **Lock down**
|
||||
the admin UI — strong password; the `_superusers` collection is the only way
|
||||
in.
|
||||
6. **SMTP** (admin UI → *Settings → Mail settings*): point at your SMTP host so
|
||||
verification / password-reset emails send. Send the test email to confirm.
|
||||
7. **Automated backups** (admin UI → *Settings → Backups*): enable the schedule
|
||||
and, ideally, S3 off-site upload. Coolify volume backups are a second layer.
|
||||
8. **App wiring** (later issues, not here): point the app's API base at
|
||||
`https://pb.petruzalekr.cz`.
|
||||
|
||||
### Verify the deploy
|
||||
|
||||
- Admin UI reachable over **HTTPS** only; HTTP redirects up.
|
||||
- Create a test user + profile + a couple of rows via admin or REST; confirm a
|
||||
second user can't read them (run `scripts/verify.py` against the live URL by
|
||||
editing `BASE`).
|
||||
- Trigger a password-reset email and confirm delivery.
|
||||
- Confirm a backup ran and can be downloaded/restored.
|
||||
|
||||
## Security
|
||||
|
||||
Account/session tokens and the addon plaintext-credential "tokens" are **never
|
||||
logged or echoed** (see the app's `CLAUDE.md`). `pinHash` is a hidden field and
|
||||
never leaves the API. `nsfwEnabled` is admin-only by rule, not just by UI.
|
||||
|
||||
## Upgrading PocketBase
|
||||
|
||||
Bump `PB_VERSION` in the `Dockerfile` (and the compose arg), rebuild, redeploy.
|
||||
Check the PocketBase release notes for migration-API or schema changes first,
|
||||
and test locally with `docker compose up --build` before deploying.
|
||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Local development / verification only. On Coolify, deploy the Dockerfile as an
|
||||
# application resource (see README) — Coolify manages the domain, TLS and volume.
|
||||
services:
|
||||
pocketbase:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
PB_VERSION: 0.39.6
|
||||
ports:
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- pb_data:/pb_data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
165
pb_migrations/1752600000_init_accounts_schema.js
Normal file
165
pb_migrations/1752600000_init_accounts_schema.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Initial account + multi-profile schema for myanime-app (epic #6, issue #9).
|
||||
//
|
||||
// Ownership model: users 1─┬─* profiles ─┬─* watch_state
|
||||
// │ ├─* watchlist
|
||||
// │ └─1 prefs
|
||||
//
|
||||
// Access rules: a user only ever touches their own `users` row, their own
|
||||
// profiles, and rows hanging off those profiles (`profile.user = auth.id`).
|
||||
// `nsfwEnabled` is admin-only: users can't submit it (superusers bypass rules
|
||||
// and set it from the admin UI). It gates adult vs clean builds in issue #16.
|
||||
|
||||
migrate((app) => {
|
||||
// ---- 1. Augment the default `users` auth collection --------------------
|
||||
const users = app.findCollectionByNameOrId("users")
|
||||
|
||||
users.fields.add(new Field({
|
||||
type: "text",
|
||||
name: "username",
|
||||
max: 150,
|
||||
presentable: true,
|
||||
}))
|
||||
users.fields.add(new Field({
|
||||
type: "bool",
|
||||
name: "nsfwEnabled",
|
||||
}))
|
||||
|
||||
// Unique username, but the field is optional — only enforce when non-empty.
|
||||
users.indexes.push(
|
||||
"CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`) WHERE `username` != ''"
|
||||
)
|
||||
|
||||
// Own record only; nsfwEnabled never accepted from a client on create/update.
|
||||
users.listRule = "id = @request.auth.id"
|
||||
users.viewRule = "id = @request.auth.id"
|
||||
users.createRule = "@request.body.nsfwEnabled:isset = false"
|
||||
users.updateRule = "id = @request.auth.id && @request.body.nsfwEnabled:isset = false"
|
||||
users.deleteRule = "id = @request.auth.id"
|
||||
|
||||
app.save(users)
|
||||
|
||||
const usersId = users.id
|
||||
|
||||
// ---- 2. profiles -------------------------------------------------------
|
||||
const profiles = new Collection({
|
||||
type: "base",
|
||||
name: "profiles",
|
||||
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
||||
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
||||
createRule: "@request.auth.id != '' && user = @request.auth.id",
|
||||
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
||||
deleteRule: "@request.auth.id != '' && user = @request.auth.id",
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "user",
|
||||
required: true,
|
||||
collectionId: usersId,
|
||||
cascadeDelete: true,
|
||||
maxSelect: 1,
|
||||
minSelect: 0,
|
||||
},
|
||||
{ type: "text", name: "name", required: true, max: 100, presentable: true },
|
||||
{
|
||||
type: "file",
|
||||
name: "avatar",
|
||||
maxSelect: 1,
|
||||
maxSize: 5242880,
|
||||
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/gif"],
|
||||
},
|
||||
{ type: "bool", name: "isChild" },
|
||||
// PIN for child profiles; hidden so it never leaves the API.
|
||||
{ type: "text", name: "pinHash", hidden: true, max: 200 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
})
|
||||
app.save(profiles)
|
||||
const profilesId = profiles.id
|
||||
|
||||
// Rows below all belong to a profile; ownership traverses profile.user.
|
||||
const OWNS = "@request.auth.id != '' && profile.user = @request.auth.id"
|
||||
|
||||
const profileRel = () => ({
|
||||
type: "relation",
|
||||
name: "profile",
|
||||
required: true,
|
||||
collectionId: profilesId,
|
||||
cascadeDelete: true,
|
||||
maxSelect: 1,
|
||||
minSelect: 0,
|
||||
})
|
||||
|
||||
// ---- 3. watch_state (resume points) ------------------------------------
|
||||
const watchState = new Collection({
|
||||
type: "base",
|
||||
name: "watch_state",
|
||||
listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS,
|
||||
fields: [
|
||||
profileRel(),
|
||||
{ type: "text", name: "itemId", required: true, max: 100 },
|
||||
{ type: "text", name: "itemType", required: true, max: 40 },
|
||||
{ type: "number", name: "position", min: 0 },
|
||||
{ type: "number", name: "duration", min: 0 },
|
||||
{ type: "autodate", name: "updatedAt", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE UNIQUE INDEX `idx_watch_state_profile_item` ON `watch_state` (`profile`, `itemId`)",
|
||||
],
|
||||
})
|
||||
app.save(watchState)
|
||||
|
||||
// ---- 4. watchlist ------------------------------------------------------
|
||||
const watchlist = new Collection({
|
||||
type: "base",
|
||||
name: "watchlist",
|
||||
listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS,
|
||||
fields: [
|
||||
profileRel(),
|
||||
{ type: "text", name: "itemId", required: true, max: 100 },
|
||||
{ type: "text", name: "itemType", required: true, max: 40 },
|
||||
{ type: "autodate", name: "addedAt", onCreate: true },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE UNIQUE INDEX `idx_watchlist_profile_item` ON `watchlist` (`profile`, `itemId`)",
|
||||
],
|
||||
})
|
||||
app.save(watchlist)
|
||||
|
||||
// ---- 5. prefs (one row per profile) ------------------------------------
|
||||
const prefs = new Collection({
|
||||
type: "base",
|
||||
name: "prefs",
|
||||
listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS,
|
||||
fields: [
|
||||
profileRel(),
|
||||
// playback / audio / subtitle preferences blob.
|
||||
{ type: "json", name: "data", maxSize: 2000000 },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE UNIQUE INDEX `idx_prefs_profile` ON `prefs` (`profile`)",
|
||||
],
|
||||
})
|
||||
app.save(prefs)
|
||||
}, (app) => {
|
||||
// ---- Down migration ----------------------------------------------------
|
||||
for (const name of ["prefs", "watchlist", "watch_state", "profiles"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name))
|
||||
} catch (_) { /* already gone */ }
|
||||
}
|
||||
|
||||
const users = app.findCollectionByNameOrId("users")
|
||||
users.indexes = users.indexes.filter((idx) => !idx.includes("idx_users_username"))
|
||||
users.fields.removeByName("username")
|
||||
users.fields.removeByName("nsfwEnabled")
|
||||
users.createRule = ""
|
||||
users.updateRule = "id = @request.auth.id"
|
||||
users.listRule = "id = @request.auth.id"
|
||||
users.viewRule = "id = @request.auth.id"
|
||||
users.deleteRule = "id = @request.auth.id"
|
||||
app.save(users)
|
||||
})
|
||||
125
scripts/verify.py
Normal file
125
scripts/verify.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""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)
|
||||
Loading…
Reference in a new issue