fix(calendar): bound recurrence work without hiding busy intervals
Module Package Release / publish-packages (push) Successful in 14s

Release v0.1.24. Coordinated integrity review: GovOPlaN/govoplan-core#298.
This commit is contained in:
2026-09-08 12:19:36 +02:00
parent 3e5fc05ca3
commit 8e36f8b3d2
10 changed files with 542 additions and 80 deletions
+30 -1
View File
@@ -3,11 +3,40 @@ from __future__ import annotations
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_calendar.backend.ical import expand_event_occurrences, event_to_ics, parse_vevent
from govoplan_calendar.backend.ical import ICalendarError, _expand_event_occurrences, expand_event_occurrences, event_to_ics, parse_vevent
class ICalendarParsingTests(unittest.TestCase):
def test_recurrence_limit_rejects_without_materializing_between(self) -> None:
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
event = SimpleNamespace(
uid="bounded@example.test", start_at=start, end_at=None, duration_seconds=None, all_day=False,
rrule={"FREQ": "SECONDLY", "COUNT": "121"}, rdate=[], exdate=[],
)
with patch("dateutil.rrule.rruleset.between", side_effect=AssertionError("Unbounded allocation")):
with self.assertRaisesRegex(ICalendarError, "result limit"):
_expand_event_occurrences(
event, start, datetime(2026, 7, 1, 0, 2, tzinfo=timezone.utc), limit=1,
)
def test_invalid_worker_ack_is_rejected(self) -> None:
from govoplan_core.security.worker_payload import encode_worker_payload
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
event = SimpleNamespace(
uid="expected@example.test", start_at=start, end_at=None, all_day=False,
rrule={"FREQ": "DAILY", "COUNT": "1"}, rdate=[], exdate=[],
)
wrong = encode_worker_payload({"occurrences": [[{
"uid": "different@example.test", "recurrence_id": "20260701T000000Z",
"start_at": start, "end_at": None, "all_day": False,
}]]})
with patch("govoplan_core.security.bounded_process.run_bounded_operation", return_value=wrong):
with self.assertRaisesRegex(ICalendarError, "invalid result"):
expand_event_occurrences(event, start, start)
def test_parse_vevent_preserves_unknown_properties_and_params(self) -> None:
payload = """BEGIN:VCALENDAR
VERSION:2.0
+160 -3
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine, create_mock_engine, event as sqlalchemy_event
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Query, Session, sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_calendar.backend.db.models import CalendarEvent
@@ -16,6 +18,8 @@ from govoplan_calendar.backend.schemas import (
)
from govoplan_calendar.backend.service import (
CalendarError,
_bounded_occurrence_candidates,
OCCURRENCE_DETAIL_FIELDS,
create_calendar,
create_event,
delete_event,
@@ -73,6 +77,159 @@ class CalendarRecurrenceAndPreferenceTests(unittest.TestCase):
),
)
def test_freebusy_returns_all_1001_hourly_occurrences(self) -> None:
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="dense@example.test", summary="Dense series",
start_at=start, end_at=start + timedelta(minutes=1),
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
),
)
self.session.commit()
busy = list_freebusy(
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
start_at=start, end_at=start + timedelta(days=43),
)
self.assertEqual(1001, len(busy))
self.assertEqual(start + timedelta(hours=1000), busy[-1]["start_at"])
def test_freebusy_over_budget_fails_without_partial_busy_slots(self) -> None:
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="excessive@example.test", summary="Excessive series",
start_at=start, end_at=start + timedelta(seconds=1),
rrule={"FREQ": "MINUTELY", "COUNT": "10001"},
),
)
self.session.commit()
with self.assertRaisesRegex(CalendarError, "could not complete"):
list_freebusy(
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
start_at=start, end_at=start + timedelta(days=8),
)
self.assertEqual(1, self.session.query(CalendarEvent).count())
def test_full_expansion_byte_cap_and_lightweight_complete_freebusy(self) -> None:
from govoplan_calendar.backend import service
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="heavy@example.test", summary="Heavy series",
description="x" * 65536, start_at=start, end_at=start + timedelta(minutes=1),
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
),
)
self.session.commit()
calendar_id = self.calendar.id
self.session.expunge_all()
with patch.object(service, "expanded_event_response", wraps=service.expanded_event_response) as expand:
with self.assertRaisesRegex(CalendarError, "response exceeds its byte limit"):
list_event_occurrences(self.session, tenant_id="tenant-1", start_at=start, end_at=start + timedelta(days=43), limit=1)
self.assertLess(expand.call_count, 70)
statements = []
loaded = []
def capture(conn, cursor, statement, parameters, context, executemany):
statements.append(statement)
def capture_loaded(session, instance):
if isinstance(instance, CalendarEvent):
loaded.append(instance)
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
sqlalchemy_event.listen(self.session, "loaded_as_persistent", capture_loaded)
try:
with patch.object(service, "event_response", side_effect=AssertionError("Full event data is unnecessary for availability")):
busy = list_freebusy(self.session, tenant_id="tenant-1", calendar_ids=[calendar_id], start_at=start, end_at=start + timedelta(days=43))
self.assertEqual(1001, len(busy))
self.assertEqual([], loaded)
for field in ("description", "raw_ics", "icalendar", "metadata", "attendees", "attachments"):
self.assertNotIn(f"calendar_events.{field}", "\n".join(statements))
finally:
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
sqlalchemy_event.remove(self.session, "loaded_as_persistent", capture_loaded)
def test_aggregate_sql_projection_hides_over_budget_values_before_driver_decoding(self) -> None:
from govoplan_calendar.backend import service
first = self.recurring_master()
second = create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="second@example.test", summary="Second",
start_at=first.start_at, end_at=first.end_at, rrule={"FREQ": "DAILY", "COUNT": "2"},
),
)
first.description = second.description = "x" * 1500
self.session.commit()
self.session.expunge_all()
captured = []
def capture(conn, cursor, statement, parameters, context, executemany):
captured.append((statement, parameters))
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
try:
with patch.object(service, "MAX_OCCURRENCE_PROJECTION_BYTES", 3000), patch.object(service, "expand_events_occurrences") as worker:
with self.assertRaisesRegex(CalendarError, "candidate projection exceeds its byte limit"):
list_event_occurrences(
self.session, tenant_id="tenant-1", start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
worker.assert_not_called()
finally:
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
self.assertEqual(1, len(captured))
statement, parameters = captured[0]
self.assertIn("sum(", statement)
self.assertIn("OVER (ORDER BY", statement)
with self.engine.connect() as connection:
raw = list(connection.exec_driver_sql(statement, parameters))
self.assertEqual(2, len(raw))
self.assertIsNotNone(raw[0][1])
self.assertTrue(all(value is None for value in raw[1][1:]))
def test_sql_projection_compiles_postgresql_without_binary_json_casts(self) -> None:
statements = []
session = Session(bind=create_mock_engine("postgresql://", lambda *args, **kwargs: None))
def inspect_query(query):
statements.append(str(query.statement.compile(dialect=postgresql.dialect())))
return iter(())
with patch.object(Query, "__iter__", inspect_query):
self.assertEqual([], _bounded_occurrence_candidates(
session.query(CalendarEvent).filter(CalendarEvent.tenant_id == "tenant-1").limit(2001),
OCCURRENCE_DETAIL_FIELDS, [4096],
))
self.assertIn("octet_length(CAST(calendar_events.icalendar AS TEXT))", statements[0])
self.assertIn("CASE WHEN", statements[0])
self.assertIn("OVER (ORDER BY", statements[0])
self.assertNotIn("BYTEA", statements[0])
def test_candidate_budget_is_not_bypassed_by_response_limit(self) -> None:
self.recurring_master()
self.session.commit()
with patch("govoplan_calendar.backend.service.MAX_OCCURRENCE_CANDIDATES", 0):
with self.assertRaisesRegex(CalendarError, "Too many recurring series"):
list_event_occurrences(
self.session, tenant_id="tenant-1", limit=1,
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
def test_admission_rejection_precedes_database_projection(self) -> None:
from govoplan_core.security.bounded_process import ProcessBudgetError
with patch("govoplan_core.security.bounded_process.bounded_operation_admission", side_effect=ProcessBudgetError("busy")):
with patch.object(self.session, "query") as query:
with self.assertRaisesRegex(CalendarError, "could not complete"):
list_event_occurrences(
self.session, tenant_id="tenant-1",
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
query.assert_not_called()
def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy(
self,
) -> None: