"""The "owner controls adding their own data; anyone linked can mute it" permission pattern, repeated across calendar-select, task-list-select, and whiteboard-source (plus the owner-only, no-mute-split calendar-color/ task-list-color) -- 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 of these 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, FrameTaskList, TaskWidgetConfig, 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 def _add_tasks_widget(db_session, frame: Frame) -> Widget: widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2, sort_order=1, created_at=time.time()) db_session.add(widget) db_session.flush() db_session.add(TaskWidgetConfig(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 # --- task-list-select --- def test_task_list_select_bob_cannot_add_alices_list(client, db_session): frame = _setup_two_linked_users(client, db_session) widget = _add_tasks_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}/task-list-select", json={ "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, }, headers=csrf_headers(client)) assert resp.status_code == 403 def test_task_list_select_owner_can_add_their_own(client, db_session): frame = _setup_two_linked_users(client, db_session) widget = _add_tasks_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}/task-list-select", json={ "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text row = db_session.query(FrameTaskList).filter_by( widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/" ).one() assert row.included is True def test_task_list_select_bob_can_mute_alices_list(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_tasks_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}/task-list-select", json={ "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, }, headers=csrf_headers(client)) client.cookies.clear() login(client, "bob") resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={ "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": False, }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text row = db_session.query(FrameTaskList).filter_by( widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/" ).one() assert row.included is False def test_task_list_select_cannot_mute_a_list_that_was_never_added(client, db_session): frame = _setup_two_linked_users(client, db_session) widget = _add_tasks_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}/task-list-select", json={ "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": False, }, headers=csrf_headers(client)) assert resp.status_code == 404 def test_task_list_select_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/task-list-select", json={ "user_id": 1, "calendar_key": "caldav:/some/tasks/", "included": True, }, headers=csrf_headers(client)) assert resp.status_code == 404 def test_task_list_select_400s_when_widget_is_not_tasks(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}/task-list-select", json={ "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, }, headers=csrf_headers(client)) assert resp.status_code == 400 # --- task-list-color --- def test_task_list_color_owner_can_pin_it(client, db_session): frame = _setup_two_linked_users(client, db_session) widget = _add_tasks_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}/task-list-select", json={ "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, }, headers=csrf_headers(client)) resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={ "calendar_key": "caldav:/alice/tasks/", "color_index": 3, }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text row = db_session.query(FrameTaskList).filter_by( widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/" ).one() assert row.color_index == 3 def test_task_list_color_bob_cannot_recolor_alices_list(client, db_session): frame = _setup_two_linked_users(client, db_session) widget = _add_tasks_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}/task-list-select", json={ "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, }, headers=csrf_headers(client)) client.cookies.clear() login(client, "bob") resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={ "calendar_key": "caldav:/alice/tasks/", "color_index": 3, }, headers=csrf_headers(client)) assert resp.status_code == 404 # bob has no row for alice's list to recolor def test_task_list_color_rejects_out_of_range_index(client, db_session): frame = _setup_two_linked_users(client, db_session) widget = _add_tasks_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}/task-list-select", json={ "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, }, headers=csrf_headers(client)) resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={ "calendar_key": "caldav:/alice/tasks/", "color_index": 99, }, 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