Files
espresso_frame/server/tests/test_permission_boundaries.py
T
tfaour 31adc34a19
Build and push server image / test (push) Successful in 1m18s
Build and push server image / build-and-push (push) Successful in 2m12s
Add a real pytest suite, gating the CI build
64 tests covering: auth/setup and the CSRF gate, the "owner adds their
own data, anyone linked can mute it" permission pattern shared across
calendar-select/tasks-source/whiteboard-source, migration correctness
(fresh install, idempotent re-run, expected columns), battery estimate
outlier rejection, calendar_feed's fetch/merge/partial-failure handling,
webdav_client's fetch/list-directory, the whiteboard force-refresh
throttle bypass and browse endpoint, and render-size invariants across
calendar views/orientations.

No DB/HTTP fixtures need Docker, Node, or a real Immich/CalDAV/WebDAV
server -- a fresh temp SQLite file plus a couple of small local HTTP
servers as test doubles cover it all. Table data is wiped and reseeded
between tests rather than relying on SQLAlchemy's transaction-rollback
isolation pattern, which needs a pysqlite event-listener workaround
app/db.py's engine doesn't have and has no reason to gain just for tests.

Wired into .gitea/workflows/server-docker-build.yml as its own job that
build-and-push now depends on, so a failing suite blocks the image push
rather than just running alongside it for show.
2026-07-23 18:51:54 -04:00

179 lines
7.1 KiB
Python

"""The "owner controls adding their own data; anyone linked can mute it"
permission pattern, repeated across calendar-select, tasks-source, and
whiteboard-source -- exercised at the HTTP layer (not just unit-level)
since the whole point is verifying the *endpoint's* authorization check,
not just a helper function's logic."""
from __future__ import annotations
from app.models import Frame, FrameCalendar, User
from .conftest import csrf_headers, link_user, login, make_user
def _setup_two_linked_users(client, db_session) -> Frame:
"""alice is the frame's admin/owner (via /setup); bob is a second
user linked to the same frame #1 but neither owns nor controls it."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
return frame
# --- whiteboard-source ---
def test_whiteboard_source_owner_can_set_it(client, db_session):
_setup_two_linked_users(client, db_session)
# alice is still logged in from /setup
resp = client.post("/api/frames/1/whiteboard-source", json={
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
assert frame.whiteboard_user_id is not None
assert frame.whiteboard_url == "https://cloud.example.com/dav/files/alice/board.whiteboard"
def test_whiteboard_source_set_always_targets_the_caller(client, db_session):
"""bob has no way to point the frame at someone else's account --
there's no target-user field in the request at all, so a "set" call
from bob always attaches to bob, even if he pastes alice's URL."""
_setup_two_linked_users(client, db_session)
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/whiteboard-source", json={
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
bob_row = db_session.query(User).filter_by(username="bob").one()
assert frame.whiteboard_user_id == bob_row.id
def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
_setup_two_linked_users(client, db_session)
client.post("/api/frames/1/whiteboard-source", json={"url": "https://cloud.example.com/board.whiteboard"},
headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/whiteboard-source", json={"url": None}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
assert frame.whiteboard_url == ""
assert frame.whiteboard_user_id is None
def test_whiteboard_source_rejects_non_http_url(client, db_session):
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "javascript:alert(1)"},
headers=csrf_headers(client))
assert resp.status_code == 400
def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session):
_setup_two_linked_users(client, db_session)
make_user(db_session, "mallory") # exists, but never linked to frame 1
client.cookies.clear()
login(client, "mallory")
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "https://x.example.com/b.whiteboard"},
headers=csrf_headers(client, "/settings"))
assert resp.status_code == 404
# --- tasks-source ---
def test_tasks_source_set_always_targets_the_caller(client, db_session):
_setup_two_linked_users(client, db_session)
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
bob_row = db_session.query(User).filter_by(username="bob").one()
frame = db_session.get(Frame, 1)
assert frame.calendar_tasks_user_id == bob_row.id
assert frame.calendar_tasks_calendar_key == "caldav:/some/tasks/"
def test_tasks_source_anyone_linked_can_clear(client, db_session):
_setup_two_linked_users(client, db_session)
client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/alice/tasks/"},
headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": None}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
assert frame.calendar_tasks_user_id is None
assert frame.calendar_tasks_calendar_key is None
# --- calendar-select ---
def test_calendar_select_bob_cannot_add_alices_calendar(client, db_session):
_setup_two_linked_users(client, db_session)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 403
def test_calendar_select_owner_can_add_their_own(client, db_session):
_setup_two_linked_users(client, db_session)
alice_id = db_session.query(User).filter_by(username="alice").one().id
resp = client.post("/api/frames/1/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
row = db_session.query(FrameCalendar).filter_by(frame_id=1, user_id=alice_id, calendar_key="ics").one()
assert row.included is True
def test_calendar_select_bob_can_mute_alices_calendar(client, db_session):
"""Muting is a display-preference veto anyone linked gets, unlike
adding -- the one-sided half of this endpoint's permission split."""
_setup_two_linked_users(client, db_session)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post("/api/frames/1/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": True,
}, headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": False,
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
row = db_session.query(FrameCalendar).filter_by(frame_id=1, user_id=alice_id, calendar_key="ics").one()
assert row.included is False
def test_calendar_select_cannot_mute_a_calendar_that_was_never_added(client, db_session):
_setup_two_linked_users(client, db_session)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": False,
}, headers=csrf_headers(client))
assert resp.status_code == 404