Tasks widgets could only ever point at one CalDAV task list (a radio- button picker, owner-only). Now they merge any number of included task lists across every linked user, same checkbox-inclusion + optional pinned-color shape a calendar widget already has for its calendars -- FrameTaskList mirrors FrameCalendar exactly, down to the same owner- adds/anyone-mutes permission split (api_widget_task_list_select/ api_widget_task_list_color). Reused calendar_render._event_colors/ _draw_color_bar as-is for the per-task color bar -- a task dict's owner_display_name/color_index is exactly that function's single- source fallback shape. Also added an opt-in "show tasks completed in the last 24 hours" toggle (TaskWidgetConfig.show_completed): caldav_client.fetch_tasks now accepts a completed_since cutoff and returns completed VTODOs (with their completion time) instead of silently dropping them, and _draw_tasks gives a completed task a filled checkbox + muted text instead of the normal empty-box/due-date row. Migration 18 splits the single-source TaskWidgetConfig columns (added by 17, splitting tasks out of the calendar widget in the first place) into frame_task_lists, carrying forward each widget's existing single source as its first included list -- same shape migration 9 used carrying forward frame_calendars' old single opt-in. Verified live in the browser (desktop + mobile): the new "Included task lists" + "Recently completed" dialog sections, the show_completed toggle actually persisting through a real HTTP round-trip, and no regression in the calendar widget's own "Included calendars" dialog. Full suite (192 tests, including new merge_tasks/config_save/migration coverage) passes.
377 lines
16 KiB
Python
377 lines
16 KiB
Python
"""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
|