Files
John SmithandClaude 1a36cb6113 feat(gateway): role-enforced handout persistence (iteration 83)
Turns iteration 31's demo-data finding into a real backend: handouts
table via the idempotent DDL pattern, full CRUD under /api/v1/handouts
with visibility enforced server-side from the HMAC-verified role —
players/spectators see only all+party rows in SQL and process
(defense-in-depth), gm_only content 404s indistinguishably from
nonexistent for unauthorized callers (no existence oracle), updates/
deletes creator-or-GM. Typed client store mirrors lobby conventions.
8 red-first tests; suite at 547. Shared auth rate-limit bucket noted
as a future test-isolation follow-up.

Co-Authored-By: Claude <[email protected]>
2026-08-24 14:32:40 -04:00

210 lines
7.3 KiB
Python

"""Handout persistence: real backend surface for GOALS.md Pillar 2.
Covers the CRUD lifecycle under /api/v1/handouts plus the role-visibility
contract (players/spectators never see ``gm_only`` rows) and auth scoping.
Identities are exercised via directly minted signed tokens rather than
/signup calls: handout authorization reads ``role`` from the token payload
alone, and the whole-suite auth rate-limit bucket (30 requests / 60 s) is
already near capacity from the other test modules.
"""
import time
import uuid
import pytest
from fastapi.testclient import TestClient
from vtt_orchestrator.server import app
client = TestClient(app)
def _token_for(user_id: str, role: str) -> str:
"""Mints a signed session token directly instead of signing up an account.
Every /api/v1/handouts route authorizes from the signed payload alone
(_caller_actor), so no DB-backed account is required here. This also keeps
the module off the shared auth rate-limit bucket (30 requests / 60 s),
which the rest of the suite already runs close to.
"""
from vtt_orchestrator.server import _sign_token
return _sign_token({"user_id": user_id, "role": role,
"exp": time.time() + 600})
@pytest.fixture(scope="module")
def gm():
return {"token": _token_for("usr_handout_gm", "gm"),
"user": {"id": "usr_handout_gm"}}
@pytest.fixture(scope="module")
def player():
return {"token": _token_for("usr_handout_player", "player"),
"user": {"id": "usr_handout_player"}}
@pytest.fixture()
def spectator_token():
return _token_for("usr_handout_spectator", "spectator")
def _create(token: str, **overrides) -> dict:
payload = {
"title": "The Sunken Crypt",
"content_md": "# The Crypt\n\n*Salt crusted stairs.*",
"revealed_to": "all",
}
payload.update(overrides)
resp = client.post("/api/v1/handouts", params={"token": token}, json=payload)
assert resp.status_code == 200, resp.text
return resp.json()
def test_handout_crud_lifecycle(gm):
created = _create(gm["token"], revealed_to="party")
assert created["title"] == "The Sunken Crypt"
assert created["revealed_to"] == "party"
assert created["content_md"].startswith("# The Crypt")
assert created["created_by"] == gm["user"]["id"]
assert created["handout_id"]
fetched = client.get(
f"/api/v1/handouts/{created['handout_id']}", params={"token": gm["token"]}
)
assert fetched.status_code == 200
assert fetched.json()["handout_id"] == created["handout_id"]
updated = client.put(
f"/api/v1/handouts/{created['handout_id']}",
params={"token": gm["token"]},
json={"title": "The Drowned Crypt", "revealed_to": "all"},
)
assert updated.status_code == 200, updated.text
body = updated.json()
assert body["title"] == "The Drowned Crypt"
assert body["revealed_to"] == "all"
# Unspecified fields survive a partial update.
assert body["content_md"].startswith("# The Crypt")
listing = client.get("/api/v1/handouts", params={"token": gm["token"]})
assert listing.status_code == 200
assert any(h["handout_id"] == created["handout_id"] for h in listing.json()["handouts"])
deleted = client.delete(
f"/api/v1/handouts/{created['handout_id']}", params={"token": gm["token"]}
)
assert deleted.status_code == 200
gone = client.get(
f"/api/v1/handouts/{created['handout_id']}", params={"token": gm["token"]}
)
assert gone.status_code == 404
def test_handout_list_scopes_by_campaign(gm):
campaign_a = str(uuid.uuid4())
campaign_b = str(uuid.uuid4())
in_a = _create(gm["token"], campaign_id=campaign_a)
in_b = _create(gm["token"], campaign_id=campaign_b)
unscoped = _create(gm["token"])
listing_a = client.get(
"/api/v1/handouts", params={"token": gm["token"], "campaign_id": campaign_a}
)
ids = {h["handout_id"] for h in listing_a.json()["handouts"]}
assert in_a["handout_id"] in ids
assert in_b["handout_id"] not in ids
assert unscoped["handout_id"] not in ids
def test_player_listing_excludes_gm_only_rows_but_sees_all_and_party(gm, player):
public = _create(gm["token"], revealed_to="all")
party = _create(gm["token"], revealed_to="party")
secret = _create(gm["token"], revealed_to="gm_only")
seen = client.get("/api/v1/handouts", params={"token": player["token"]}).json()
ids = {h["handout_id"] for h in seen["handouts"]}
assert public["handout_id"] in ids
assert party["handout_id"] in ids
assert secret["handout_id"] not in ids
# Direct GET on a gm_only row must not leak it either (no existence oracle).
direct = client.get(
f"/api/v1/handouts/{secret['handout_id']}", params={"token": player["token"]}
)
assert direct.status_code == 404
def test_spectator_listing_also_excludes_gm_only(gm, spectator_token):
secret = _create(gm["token"], revealed_to="gm_only")
public = _create(gm["token"], revealed_to="all")
seen = client.get("/api/v1/handouts", params={"token": spectator_token}).json()
ids = {h["handout_id"] for h in seen["handouts"]}
assert public["handout_id"] in ids
assert secret["handout_id"] not in ids
def test_invalid_revealed_to_rejected(gm):
resp = client.post(
"/api/v1/handouts",
params={"token": gm["token"]},
json={"title": "X", "content_md": "", "revealed_to": "everyone"},
)
assert resp.status_code == 422
def test_handout_update_delete_by_creator_not_role(gm, player):
"""Creator-scoped mutation: a player-authored note stays editable by its
author even though they are not a GM."""
authored = _create(player["token"], revealed_to="party")
renamed = client.put(
f"/api/v1/handouts/{authored['handout_id']}",
params={"token": player["token"]},
json={"title": "My Notes"},
)
assert renamed.status_code == 200
assert renamed.json()["title"] == "My Notes"
deleted = client.delete(
f"/api/v1/handouts/{authored['handout_id']}", params={"token": player["token"]}
)
assert deleted.status_code == 200
def test_foreign_user_delete_and_update_404(gm, player):
"""A valid token belonging to neither the creator nor a GM gets the same
404 as a nonexistent id — mutation routes cannot be probed as an
existence oracle."""
created = _create(gm["token"])
stolen_delete = client.delete(
f"/api/v1/handouts/{created['handout_id']}", params={"token": player["token"]}
)
assert stolen_delete.status_code == 404
stolen_update = client.put(
f"/api/v1/handouts/{created['handout_id']}",
params={"token": player["token"]},
json={"title": "Hijacked"},
)
assert stolen_update.status_code == 404
garbage = client.delete(
f"/api/v1/handouts/{uuid.uuid4()}", params={"token": player["token"]}
)
assert garbage.status_code == 404
def test_handout_routes_require_auth(gm):
created = _create(gm["token"])
assert client.post("/api/v1/handouts", json={"title": "x"}).status_code == 401
assert client.get("/api/v1/handouts").status_code == 401
assert client.get(f"/api/v1/handouts/{created['handout_id']}").status_code == 401
assert client.put(
f"/api/v1/handouts/{created['handout_id']}", json={"title": "x"}
).status_code == 401
assert client.delete(f"/api/v1/handouts/{created['handout_id']}").status_code == 401