Files
espresso_frame/server/tests/test_permission_boundaries.py
T
tfaour a33a3a71e4
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m57s
Build and push server image / deploy (push) Successful in 52s
Widget system Phase 4b: per-widget gear-icon config dialogs
Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.

Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.

API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.

UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.

Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
2026-07-24 14:31:24 -04:00

278 lines
12 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. All three now live under
/api/frames/{id}/widgets/{widget_id}/... (see routers/api_widgets.py)."""
from __future__ import annotations
import time
from app.models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
User,
Widget,
WhiteboardWidgetConfig,
)
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
def _photo_widget_id(db_session, frame: Frame) -> int:
"""Frame #1's auto-migrated widget -- used to exercise the "wrong
widget type" 400 case (e.g. posting a whiteboard-source to a photos
widget)."""
return db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
"""Frame #1's auto-migrated widget is a photos widget (its mode was
"photos" before the widget system existed) -- these tests need a
whiteboard widget too, added directly here the same way the widget
unit tests do."""
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _add_calendar_widget(db_session, frame: Frame) -> Widget:
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
# --- whiteboard-source ---
def test_whiteboard_source_owner_can_set_it(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
# alice is still logged in from /setup
resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", json={
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
assert cfg.user_id is not None
assert cfg.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 widget 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."""
frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", json={
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
bob_row = db_session.query(User).filter_by(username="bob").one()
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
assert cfg.user_id == bob_row.id
def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source",
json={"url": "https://cloud.example.com/board.whiteboard"},
headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", json={"url": None},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
assert cfg.url == ""
assert cfg.user_id is None
def test_whiteboard_source_rejects_non_http_url(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{widget.id}/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):
frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
make_user(db_session, "mallory") # exists, but never linked to frame 1
client.cookies.clear()
login(client, "mallory")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source",
json={"url": "https://x.example.com/b.whiteboard"},
headers=csrf_headers(client, "/settings"))
assert resp.status_code == 404
def test_whiteboard_source_404s_for_a_widget_id_that_does_not_exist(client, db_session):
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/widgets/999999/whiteboard-source",
json={"url": "https://x.example.com/b.whiteboard"}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_session):
"""A linked, fully-permitted owner hitting this endpoint on a widget
that exists but is the wrong type (frame #1's auto-migrated widget
is photos, not whiteboard)."""
frame = _setup_two_linked_users(client, db_session)
photo_widget_id = _photo_widget_id(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/whiteboard-source",
json={"url": "https://x.example.com/b.whiteboard"}, headers=csrf_headers(client))
assert resp.status_code == 400
# --- tasks-source ---
def test_tasks_source_set_always_targets_the_caller(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/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()
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.tasks_user_id == bob_row.id
assert cfg.tasks_calendar_key == "caldav:/some/tasks/"
def test_tasks_source_anyone_linked_can_clear(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", json={"calendar_key": None},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.tasks_user_id is None
assert cfg.tasks_calendar_key is None
def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session):
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/widgets/999999/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_tasks_source_400s_when_widget_is_not_a_calendar(client, db_session):
frame = _setup_two_linked_users(client, db_session)
photo_widget_id = _photo_widget_id(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
assert resp.status_code == 400
# --- calendar-select ---
def test_calendar_select_bob_cannot_add_alices_calendar(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/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):
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
resp = client.post(f"/api/frames/1/widgets/{widget.id}/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(widget_id=widget.id, 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."""
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post(f"/api/frames/1/widgets/{widget.id}/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(f"/api/frames/1/widgets/{widget.id}/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(widget_id=widget.id, 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):
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": False,
}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_calendar_select_400s_when_widget_is_not_a_calendar(client, db_session):
frame = _setup_two_linked_users(client, db_session)
photo_widget_id = _photo_widget_id(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 400