diff --git a/src/task_scheduler.py b/src/task_scheduler.py index 6c8ab148a..b84632e43 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -236,6 +236,29 @@ def _digest_windows(now): ] +def _checkin_calendar_events(db, owner, start, end): + """Calendar events in [start, end] for ONE owner, for the check-in digest. + + Ownership lives on CalendarCal.owner; events inherit it via calendar_id. + The digest query had no owner scope, so it pulled EVERY user's events into + one user's check-in (a cross-tenant leak of summaries/locations). Scope it + by joining CalendarCal, mirroring routes/calendar_routes.list_events. + """ + from core.database import CalendarEvent as _CE, CalendarCal as _CC + return ( + db.query(_CE) + .join(_CC, _CE.calendar_id == _CC.id) + .filter( + _CC.owner == owner, + _CE.dtstart >= start, + _CE.dtstart <= end, + _CE.status != "cancelled", + ) + .order_by(_CE.dtstart) + .all() + ) + + class TaskScheduler: def __init__(self, session_manager): self._session_manager = session_manager @@ -1127,11 +1150,7 @@ class TaskScheduler: # Strip timezone for naive DB comparison _s = start.replace(tzinfo=None) if start.tzinfo else start _e = end.replace(tzinfo=None) if end.tzinfo else end - evs = _db.query(_CE).filter( - _CE.dtstart >= _s, - _CE.dtstart <= _e, - _CE.status != "cancelled", - ).order_by(_CE.dtstart).all() + evs = _checkin_calendar_events(_db, task.owner, _s, _e) if not evs: continue # Group by importance for richer output diff --git a/tests/test_checkin_digest_owner_scope.py b/tests/test_checkin_digest_owner_scope.py new file mode 100644 index 000000000..a2e8ebb17 --- /dev/null +++ b/tests/test_checkin_digest_owner_scope.py @@ -0,0 +1,70 @@ +"""Check-in calendar digest must be scoped to the task owner. + +The digest query selected CalendarEvent with no owner scope, so a scheduled +check-in for one user pulled EVERY user's calendar events (summaries, +locations) into their digest — a cross-tenant leak. Ownership lives on +CalendarCal.owner; the query must join it, like routes/calendar_routes. +""" +import tempfile +import uuid +from datetime import datetime + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +import core.database as cdb +from core.database import CalendarEvent, CalendarCal +from src.task_scheduler import _checkin_calendar_events + +_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_ENGINE = create_engine(f"sqlite:///{_TMPDB.name}", connect_args={"check_same_thread": False}, poolclass=NullPool) +cdb.Base.metadata.create_all(_ENGINE) +_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False) + + +def _seed(): + db = _TS() + try: + db.query(CalendarEvent).delete(); db.query(CalendarCal).delete() + db.add(CalendarCal(id="calA", owner="alice", name="A")) + db.add(CalendarCal(id="calB", owner="bob", name="B")) + db.add(CalendarEvent(uid="a1", calendar_id="calA", summary="Alice mtg", + dtstart=datetime(2026, 6, 10, 9, 0), + dtend=datetime(2026, 6, 10, 10, 0), status="confirmed")) + db.add(CalendarEvent(uid="b1", calendar_id="calB", summary="Bob secret", + dtstart=datetime(2026, 6, 10, 10, 0), + dtend=datetime(2026, 6, 10, 11, 0), status="confirmed")) + db.commit() + finally: + db.close() + + +def test_digest_only_returns_owner_events(): + _seed() + db = _TS() + try: + s, e = datetime(2026, 6, 1), datetime(2026, 6, 30) + alice = _checkin_calendar_events(db, "alice", s, e) + assert [ev.summary for ev in alice] == ["Alice mtg"] # not Bob's + bob = _checkin_calendar_events(db, "bob", s, e) + assert [ev.summary for ev in bob] == ["Bob secret"] + finally: + db.close() + + +def test_cancelled_excluded_and_window_respected(): + _seed() + db = _TS() + try: + db2 = _TS() + db2.add(CalendarEvent(uid="a2", calendar_id="calA", summary="cancelled", + dtstart=datetime(2026, 6, 11), + dtend=datetime(2026, 6, 11, 1, 0), status="cancelled")) + db2.commit(); db2.close() + s, e = datetime(2026, 6, 1), datetime(2026, 6, 30) + out = _checkin_calendar_events(db, "alice", s, e) + assert "cancelled" not in [ev.summary for ev in out] + finally: + db.close()