diff --git a/package.json b/package.json index abce08a..b0d99f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/calendar-webui", - "version": "0.1.23", + "version": "0.1.24", "private": true, "type": "module", "main": "webui/src/index.ts", diff --git a/pyproject.toml b/pyproject.toml index f1698eb..6b473b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-calendar" -version = "0.1.23" +version = "0.1.24" description = "GovOPlaN calendar module with VEVENT storage and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.44", + "govoplan-core>=0.1.46", "govoplan-access>=0.1.18", "defusedxml>=0.7,<1", "icalendar>=7.2", diff --git a/src/govoplan_calendar/__init__.py b/src/govoplan_calendar/__init__.py index ec15811..4093e9f 100644 --- a/src/govoplan_calendar/__init__.py +++ b/src/govoplan_calendar/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.1.23" +__version__ = "0.1.24" diff --git a/src/govoplan_calendar/backend/ical.py b/src/govoplan_calendar/backend/ical.py index c6f7640..0f05c29 100644 --- a/src/govoplan_calendar/backend/ical.py +++ b/src/govoplan_calendar/backend/ical.py @@ -215,7 +215,82 @@ def add_event_raw_records(component: Event, event: Any) -> None: add_raw_property(component, "RELATED-TO", record) -def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]: +def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 10_000) -> list[dict[str, Any]]: + """Return every occurrence in range, or explicitly reject an unsafe expansion.""" + return expand_events_occurrences([event], range_start, range_end, limit=limit)[0] + + +def expand_events_occurrences( + events: list[Any], range_start: datetime, range_end: datetime, + *, limit: int = 10_000, admission: Any = None, +) -> list[list[dict[str, Any]]]: + from govoplan_core.security.bounded_process import ( + ProcessBudgetError, bounded_operation_admission, run_bounded_operation, + ) + from govoplan_core.security.worker_payload import ( + WorkerPayloadError, decode_worker_payload, encode_worker_payload, + ) + from govoplan_calendar.backend.recurrence_worker import RECURRENCE_LIMITS, expand_recurrences_worker + + if not events: + return [] + if type(limit) is not int or not 1 <= limit <= 10_000 or len(events) > 2_000: + raise ICalendarError("Recurrence expansion exceeds its limit; request a smaller range or fewer calendars.") + try: + if admission is None: + with bounded_operation_admission() as reserved: + return expand_events_occurrences(events, range_start, range_end, limit=limit, admission=reserved) + projections = [ + { + "uid": item.uid, + "start_at": item.start_at, + "end_at": item.end_at, + "duration_seconds": getattr(item, "duration_seconds", None), + "all_day": item.all_day, + "timezone": getattr(item, "timezone", None), + "rrule": item.rrule, + "rdate": item.rdate or [], + "exdate": item.exdate or [], + } + for item in events + ] + payload = encode_worker_payload( + {"events": projections, "start": range_start, "end": range_end, "limit": limit}, + max_bytes=RECURRENCE_LIMITS.input_bytes, + ) + result = decode_worker_payload( + run_bounded_operation(expand_recurrences_worker, payload, limits=RECURRENCE_LIMITS, admission=admission), + max_bytes=RECURRENCE_LIMITS.output_bytes, + ) + if not isinstance(result, dict) or result.get("error"): + raise ICalendarError("Recurrence expansion is invalid or exceeds its limit; request a smaller range or fewer calendars.") + batches = result.get("occurrences") + if not isinstance(batches, list) or len(batches) != len(events): + raise ICalendarError("Recurrence worker returned an invalid result.") + count = 0 + for source, batch in zip(events, batches, strict=True): + if not isinstance(batch, list): + raise ICalendarError("Recurrence worker returned an invalid result.") + count += len(batch) + if count > limit: + raise ICalendarError("Recurrence worker exceeded its result limit.") + for item in batch: + if ( + not isinstance(item, dict) + or set(item) != {"uid", "recurrence_id", "start_at", "end_at", "all_day"} + or item["uid"] != source.uid + or not isinstance(item["recurrence_id"], str) + or not isinstance(item["start_at"], datetime) + or (item["end_at"] is not None and not isinstance(item["end_at"], datetime)) + or type(item["all_day"]) is not bool + ): + raise ICalendarError("Recurrence worker returned an invalid result.") + return batches + except (ProcessBudgetError, WorkerPayloadError) as exc: + raise ICalendarError("Recurrence expansion could not complete within its resource limits; narrow the request or retry later.") from exc + + +def _expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 10_000) -> list[dict[str, Any]]: """Expand an event's recurrence primitives within a range. This is a backend primitive for later API/UI recurrence handling. It expands @@ -240,14 +315,20 @@ def expand_event_occurrences(event: Any, range_start: datetime, range_end: datet for exdate in temporal_values_from_records(event.exdate or []): rules.exdate(exdate) - starts = rules.between(range_start - duration, range_end, inc=True) occurrences: list[dict[str, Any]] = [] - for occurrence_start in starts: + # Never materialize rules.between(): dense rules allocate the whole range + # before a result limit can run. Sparse rules and pre-range scans are also + # covered by the disposable worker's CPU/wall/memory limits. + for scanned, occurrence_start in enumerate(rules, start=1): + if scanned > 100_000: + raise ICalendarError("Recurrence scan limit exceeded.") occurrence_start = normalize_datetime(occurrence_start) + if occurrence_start > range_end: + break if occurrence_overlaps(occurrence_start, duration, range_start, range_end): - occurrences.append(occurrence_payload(occurrence_start, duration, event)) if len(occurrences) >= limit: - break + raise ICalendarError("Recurrence result limit exceeded.") + occurrences.append(occurrence_payload(occurrence_start, duration, event)) return occurrences diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index b039340..3a3c103 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -620,7 +620,7 @@ def _open_xchange_provider_states(context): manifest = ModuleManifest( id="calendar", name="Calendar", - version="0.1.23", + version="0.1.24", required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -691,6 +691,54 @@ manifest = ModuleManifest( ), ), documentation=( + DocumentationTopic( + id="calendar.complete-bounded-availability", + title="Complete availability and bounded recurrence expansion", + summary="Availability either includes every busy occurrence or reports that the request could not complete.", + body=( + "Expanded ranges are limited to 400 days. A request admits at most 2,000 calendars and loads at most 2,000 " + "series, direct events, or override candidates per category. Recurrences run together in one disposable, " + "data-only worker with shared non-queuing admission, 5 seconds wall time, 3 seconds CPU, 256 MiB memory, " + "and 4 MiB input/output budgets. Results are limited to 10,000 occurrences in total; each series scans at " + "most 100,000 recurrence candidates. These are resource boundaries, not an arbitrary-code sandbox. " + "On SQLite and PostgreSQL, candidate identity/size projections enforce a shared 4 MiB data budget in SQL " + "before text/JSON fields reach the application; raw iCalendar source is never loaded for expansion. The full " + "expanded response has a separate 4 MiB JSON byte limit, including repeated descriptions and metadata. " + "Free/busy loads only scheduling fields, so large descriptions or attachments do not prevent an otherwise " + "bounded, complete availability answer. Unsupported database dialects fail explicitly. " + "An exceeded limit or unavailable worker causes an explicit error, never a partial free/busy answer. " + "Narrow the range or calendars, or retry if capacity is busy. A UI response limit only slices a successfully " + "completed result; it does not hide failed expansion. Existing tenant/calendar visibility, overrides, " + "cancellations, exclusions, timezone handling, and stored iCalendar properties remain unchanged. " + "Verifying an individual recurrence uses the same worker boundary; failure does not edit the occurrence." + ), + layer="always", documentation_types=("user", "admin"), + audience=("user", "calendar_manager", "administrator"), order=19, + translations={"de": { + "title": "Vollständige Verfügbarkeit und begrenzte Serienberechnung", + "summary": "Die Verfügbarkeit enthält alle belegten Termine oder meldet ausdrücklich eine unvollendete Anfrage.", + "body": ( + "Erweiterte Zeiträume sind auf 400 Tage begrenzt. Eine Anfrage umfasst höchstens 2.000 Kalender und lädt " + "jeweils höchstens 2.000 Serien-, Direkttermin- oder Ausnahmekandidaten. Serien werden gemeinsam in einem " + "kurzlebigen Worker ohne Datenbankzugriff berechnet: gemeinsame Zulassung ohne Warteschlange, 5 Sekunden " + "Laufzeit, 3 Sekunden CPU, 256 MiB Speicher und je 4 MiB Ein-/Ausgabe. Insgesamt sind höchstens 10.000 " + "Vorkommen erlaubt; pro Serie werden maximal 100.000 Kandidaten durchlaufen. Dies ist eine Ressourcengrenze, " + "keine Sandbox für beliebigen Code. Unter SQLite und PostgreSQL begrenzen Kennungs-/Größenprojektionen " + "bereits in SQL die gemeinsam geladenen Kandidatendaten auf 4 MiB, bevor Text-/JSON-Felder die Anwendung " + "erreichen; die rohe iCalendar-Quelle wird für die Erweiterung nicht geladen. Die vollständige erweiterte " + "Antwort hat ein separates JSON-Bytelimit von 4 MiB einschließlich wiederholter Beschreibungen und Metadaten. " + "Frei/Belegt lädt nur Planungsfelder, sodass große Beschreibungen oder Anhänge eine ansonsten begrenzte, " + "vollständige Verfügbarkeitsantwort nicht verhindern. Nicht unterstützte Datenbankdialekte melden einen " + "ausdrücklichen Fehler. Überschrittene Grenzen oder nicht verfügbare Worker erzeugen einen " + "ausdrücklichen Fehler, niemals eine unvollständige Frei-/Belegt-Antwort. Zeitraum oder Kalenderauswahl " + "verkleinern beziehungsweise bei ausgelasteter Kapazität später erneut versuchen. Ein UI-Ausgabelimit kürzt " + "nur ein vollständig berechnetes Ergebnis und verdeckt keine fehlgeschlagene Berechnung. Bestehende " + "Mandanten-/Kalendersichtbarkeit, Ausnahmen, Absagen, Ausschlüsse, Zeitzonenbehandlung und gespeicherte " + "iCalendar-Eigenschaften bleiben erhalten. Einzelne Serienvorkommen werden unter derselben Worker-Grenze " + "geprüft; ein Fehlschlag ändert das Vorkommen nicht." + ), + }}, + ), DocumentationTopic( id="calendar.privacy.data-subject-requests", title="Review Calendar data in a data-subject request", diff --git a/src/govoplan_calendar/backend/recurrence_worker.py b/src/govoplan_calendar/backend/recurrence_worker.py new file mode 100755 index 0000000..3b3b7f1 --- /dev/null +++ b/src/govoplan_calendar/backend/recurrence_worker.py @@ -0,0 +1,44 @@ +"""Data-only recurrence work; resource boundary, not an arbitrary-code sandbox.""" +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace + +from govoplan_core.security.bounded_process import ProcessLimits +from govoplan_core.security.worker_payload import decode_worker_payload, encode_worker_payload + +RECURRENCE_LIMITS = ProcessLimits( + wall_seconds=5, cpu_seconds=3, memory_bytes=256 * 1024 * 1024, + input_bytes=4 * 1024 * 1024, output_bytes=4 * 1024 * 1024, +) + + +def expand_recurrences_worker(payload: bytes) -> bytes: + from govoplan_calendar.backend.ical import _expand_event_occurrences + + value = decode_worker_payload(payload, max_bytes=RECURRENCE_LIMITS.input_bytes) + try: + if not isinstance(value, dict) or set(value) != {"events", "start", "end", "limit"}: + raise ValueError("Invalid request") + if not isinstance(value["events"], list) or len(value["events"]) > 2_000: + raise ValueError("Invalid event count") + if type(value["limit"]) is not int or not 1 <= value["limit"] <= 10_000: + raise ValueError("Invalid result limit") + if not isinstance(value["start"], datetime) or not isinstance(value["end"], datetime): + raise ValueError("Invalid range") + remaining = value["limit"] + batches = [] + for event in value["events"]: + if not isinstance(event, dict) or set(event) != { + "uid", "start_at", "end_at", "duration_seconds", "all_day", "timezone", "rrule", "rdate", "exdate", + }: + raise ValueError("Invalid event") + batch = _expand_event_occurrences( + SimpleNamespace(**event), value["start"], value["end"], limit=remaining, + ) + remaining -= len(batch) + batches.append(batch) + result = {"occurrences": batches} + except (ValueError, TypeError, OverflowError, KeyError, AttributeError): + result = {"error": "invalid_or_excessive_recurrence"} + return encode_worker_payload(result, max_bytes=RECURRENCE_LIMITS.output_bytes) diff --git a/src/govoplan_calendar/backend/service.py b/src/govoplan_calendar/backend/service.py index 00c76ab..5d5cbf9 100644 --- a/src/govoplan_calendar/backend/service.py +++ b/src/govoplan_calendar/backend/service.py @@ -11,9 +11,10 @@ import urllib.error import urllib.request from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone +from types import SimpleNamespace from typing import Any, Callable, Iterable -from sqlalchemy import func, or_ +from sqlalchemy import LargeBinary, Text, case, cast, func, or_, select from sqlalchemy.orm import Session from govoplan_core.security.outbound_http import ( @@ -58,7 +59,9 @@ from govoplan_calendar.backend.ews import ( ) from govoplan_calendar.backend.graph import graph_event_payload from govoplan_calendar.backend.ical import ( + ICalendarError, expand_event_occurrences, + expand_events_occurrences, normalized_recurrence_id, parse_vevent, parse_vevents, @@ -102,6 +105,19 @@ CALENDAR_EVENT_RESOURCE = "calendar_event" SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500 REMOTE_SYNC_MAX_ITEMS = 10_000 MAX_OCCURRENCE_RANGE_DAYS = 400 +MAX_OCCURRENCE_CANDIDATES = 2_000 +MAX_OCCURRENCE_RESULTS = 10_000 +MAX_OCCURRENCE_PROJECTION_BYTES = 4 * 1024 * 1024 +MAX_OCCURRENCE_RESPONSE_BYTES = 4 * 1024 * 1024 +OCCURRENCE_AVAILABILITY_FIELDS = ( + "id", "calendar_id", "uid", "recurrence_id", "status", "transparency", + "start_at", "end_at", "duration_seconds", "all_day", "timezone", "rrule", "rdate", "exdate", +) +OCCURRENCE_DETAIL_FIELDS = OCCURRENCE_AVAILABILITY_FIELDS + ( + "tenant_id", "sequence", "summary", "description", "location", "classification", + "organizer", "attendees", "categories", "reminders", "attachments", "related_to", + "source_kind", "source_href", "etag", "icalendar", "created_at", "updated_at", "metadata_", +) DEFAULT_CALENDAR_VIEW_PREFERENCES: dict[str, bool | int] = { "dim_weekends": True, "dim_off_hours": True, @@ -3850,6 +3866,60 @@ def list_events( return query.all() +def _bounded_occurrence_candidates(query, fields: tuple[str, ...], remaining: list[int]): + """Project only needed columns, hiding oversized values inside the SQL read. + + A separate size-check query followed by ORM loading would race an update and + would still decode all JSON/Text in the driver before checking the budget. + CASE and the measured values here share one statement snapshot; a cumulative + SQL budget bounds all payload columns before the driver can decode them. + """ + dialect = query.session.get_bind().dialect.name + if dialect not in {"sqlite", "postgresql"}: + raise CalendarError("Bounded calendar projections require SQLite or PostgreSQL.") + columns = [getattr(CalendarEvent, name) for name in fields] + sizes = [] + for column in columns: + text_value = cast(column, Text) + byte_length = func.length(cast(text_value, LargeBinary)) if dialect == "sqlite" else func.octet_length(text_value) + sizes.append(func.coalesce(byte_length, 0)) + size = sum(sizes) + sizes_query = query.with_entities( + CalendarEvent.id.label("candidate_id"), size.label("projection_bytes"), + ).subquery() + budgeted = select( + sizes_query, + func.sum(sizes_query.c.projection_bytes).over(order_by=sizes_query.c.candidate_id).label("cumulative_bytes"), + ).subquery() + # The window is evaluated on small identity/size rows before selecting any + # payload. Even a buffering driver/sort can receive at most the remaining + # aggregate bytes, rather than thousands of individually allowed big events. + projected = query.session.query( + budgeted.c.projection_bytes, + *(case((budgeted.c.cumulative_bytes <= remaining[0], column), else_=None).label(name) + for name, column in zip(fields, columns, strict=True)), + ).join(CalendarEvent, CalendarEvent.id == budgeted.c.candidate_id).order_by(budgeted.c.candidate_id) + rows = [] + iterator = iter(projected.yield_per(1)) + try: + for row in iterator: + remaining[0] -= row.projection_bytes + if remaining[0] < 0: + raise CalendarError("Calendar candidate projection exceeds its byte limit; request fewer calendars or a smaller range.") + rows.append(SimpleNamespace(**{name: getattr(row, name) for name in fields})) + finally: + close = getattr(iterator, "close", None) + if close is not None: + close() + return rows + + +def _occurrence_json_default(value: object) -> str: + if isinstance(value, datetime): + return value.isoformat() + raise CalendarError("Calendar occurrence data cannot be encoded safely.") + + def list_event_occurrences( session: Session, *, @@ -3859,6 +3929,25 @@ def list_event_occurrences( calendar_id: str | None = None, visible_calendar_ids: Iterable[str] | None = None, limit: int | None = None, + _availability_only: bool = False, +) -> list[dict[str, Any]]: + from govoplan_core.security.bounded_process import ProcessBudgetError, bounded_operation_admission + + try: + with bounded_operation_admission() as admission: + return _list_event_occurrences( + session, tenant_id=tenant_id, start_at=start_at, end_at=end_at, + calendar_id=calendar_id, visible_calendar_ids=visible_calendar_ids, + limit=limit, admission=admission, availability_only=_availability_only, + ) + except (ProcessBudgetError, ICalendarError) as exc: + raise CalendarError("Calendar expansion could not complete within its limits; request a smaller range or fewer calendars, or retry later.") from exc + + +def _list_event_occurrences( + session: Session, *, tenant_id: str, start_at: datetime, end_at: datetime, + calendar_id: str | None, visible_calendar_ids: Iterable[str] | None, + limit: int | None, admission: Any, availability_only: bool = False, ) -> list[dict[str, Any]]: """Return range-bounded events with recurring series fully reconciled.""" @@ -3879,13 +3968,17 @@ def list_event_occurrences( normalized_calendar_ids = tuple(dict.fromkeys(visible_calendar_ids)) if not normalized_calendar_ids: return [] + if len(normalized_calendar_ids) > MAX_OCCURRENCE_CANDIDATES: + raise CalendarError("Too many calendars for complete expansion; request fewer calendars.") base_query = base_query.filter( CalendarEvent.calendar_id.in_(normalized_calendar_ids) ) if calendar_id: base_query = base_query.filter(CalendarEvent.calendar_id == calendar_id) - recurring_masters = ( + fields = OCCURRENCE_AVAILABILITY_FIELDS if availability_only else OCCURRENCE_DETAIL_FIELDS + projection_budget = [MAX_OCCURRENCE_PROJECTION_BYTES] + recurring_masters = _bounded_occurrence_candidates( base_query.filter( CalendarEvent.recurrence_id.is_(None), or_( @@ -3894,9 +3987,11 @@ def list_event_occurrences( ), ) .order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc()) - .all() + .limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget, ) - direct_events = ( + if len(recurring_masters) > MAX_OCCURRENCE_CANDIDATES: + raise CalendarError("Too many recurring series for complete expansion; request fewer calendars.") + direct_events = _bounded_occurrence_candidates( base_query.filter( or_( CalendarEvent.end_at.is_(None), @@ -3905,8 +4000,10 @@ def list_event_occurrences( CalendarEvent.start_at <= range_end, ) .order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc()) - .all() + .limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget, ) + if len(direct_events) > MAX_OCCURRENCE_CANDIDATES: + raise CalendarError("Too many events for complete expansion; request a smaller range or fewer calendars.") series_keys = { (event.calendar_id, event.uid) for event in recurring_masters @@ -3914,12 +4011,16 @@ def list_event_occurrences( overrides: list[CalendarEvent] = [] if series_keys: series_uids = {uid for _calendar_id, uid in series_keys} - overrides = [ - event - for event in base_query.filter( + override_candidates = _bounded_occurrence_candidates( + base_query.filter( CalendarEvent.recurrence_id.is_not(None), CalendarEvent.uid.in_(series_uids), - ).all() + ).limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget, + ) + if len(override_candidates) > MAX_OCCURRENCE_CANDIDATES: + raise CalendarError("Too many overrides for complete expansion; request fewer calendars.") + overrides = [ + event for event in override_candidates if (event.calendar_id, event.uid) in series_keys ] @@ -3934,19 +4035,31 @@ def list_event_occurrences( )[recurrence_key] = override results: list[dict[str, Any]] = [] + response_bytes = 2 # JSON list brackets; charge the complete unsliced result. + + def append_occurrence(event, **kwargs) -> None: + nonlocal response_bytes + if len(results) >= MAX_OCCURRENCE_RESULTS: + raise CalendarError("Too many occurrences for a complete result; request a smaller range or fewer calendars.") + payload = expanded_event_response(event, availability_only=availability_only, **kwargs) + response_bytes += len(json.dumps(payload, default=_occurrence_json_default, ensure_ascii=True).encode("utf-8")) + 2 + if response_bytes > MAX_OCCURRENCE_RESPONSE_BYTES: + raise CalendarError("Expanded calendar response exceeds its byte limit; request a smaller range or fewer calendars.") + results.append(payload) + consumed_event_ids: set[str] = set() recurring_master_ids = {event.id for event in recurring_masters} recurring_master_by_series = { (event.calendar_id, event.uid): event for event in recurring_masters } - for master in recurring_masters: + expanded_batches = expand_events_occurrences( + recurring_masters, range_start, range_end, + limit=MAX_OCCURRENCE_RESULTS, admission=admission, + ) + for master, expanded in zip(recurring_masters, expanded_batches, strict=True): series_key = (master.calendar_id, master.uid) series_overrides = overrides_by_series.get(series_key, {}) - for occurrence in expand_event_occurrences( - master, - range_start, - range_end, - ): + for occurrence in expanded: occurrence_recurrence_id = str( occurrence.get("recurrence_id") or "" ) @@ -3967,25 +4080,18 @@ def list_event_occurrences( range_start=range_start, range_end=range_end, ): - results.append( - expanded_event_response( - override, - series_event_id=master.id, - recurrence_id=( - override.recurrence_id - or occurrence_recurrence_id - ), - is_override=True, - ) + append_occurrence( + override, + series_event_id=master.id, + recurrence_id=(override.recurrence_id or occurrence_recurrence_id), + is_override=True, ) continue - results.append( - expanded_event_response( - master, - series_event_id=master.id, - recurrence_id=occurrence_recurrence_id, - occurrence=occurrence, - ) + append_occurrence( + master, + series_event_id=master.id, + recurrence_id=occurrence_recurrence_id, + occurrence=occurrence, ) for event in direct_events: @@ -4002,20 +4108,18 @@ def list_event_occurrences( is_override = True if event.status.upper() == "CANCELLED": continue - results.append( - expanded_event_response( - event, - series_event_id=series_event_id, - recurrence_id=normalized_recurrence_id(event.recurrence_id), - is_override=is_override, - ) + append_occurrence( + event, + series_event_id=series_event_id, + recurrence_id=normalized_recurrence_id(event.recurrence_id), + is_override=is_override, ) sorted_results = sorted( results, key=lambda item: ( item["start_at"], item["calendar_id"], - item["summary"], + item.get("summary", ""), item["instance_id"], ), ) @@ -4044,8 +4148,16 @@ def expanded_event_response( recurrence_id: str | None, occurrence: dict[str, Any] | None = None, is_override: bool = False, + availability_only: bool = False, ) -> dict[str, Any]: - payload = event_response(event) + payload = ( + { + "id": event.id, "calendar_id": event.calendar_id, "uid": event.uid, + "start_at": response_datetime(event.start_at), "end_at": response_datetime(event.end_at), + "all_day": event.all_day, "status": event.status, "transparency": event.transparency, + } + if availability_only else event_response(event) + ) if occurrence is not None: payload["start_at"] = response_datetime(occurrence["start_at"]) payload["end_at"] = response_datetime(occurrence.get("end_at")) @@ -4074,25 +4186,13 @@ def list_freebusy( range_end = normalize_datetime(end_at) if range_end < range_start: raise CalendarError("Free/busy end must be after start") - events: list[dict[str, Any]] = [] - if calendar_ids: - for calendar_id in dict.fromkeys(calendar_ids): - events.extend( - list_event_occurrences( - session, - tenant_id=tenant_id, - calendar_id=calendar_id, - start_at=range_start, - end_at=range_end, - ) - ) - else: - events = list_event_occurrences( - session, - tenant_id=tenant_id, - start_at=range_start, - end_at=range_end, - ) + # One admission and one worker budget for the entire requested collection, + # not one independently reset expansion allowance per calendar. + events = list_event_occurrences( + session, tenant_id=tenant_id, start_at=range_start, end_at=range_end, + visible_calendar_ids=calendar_ids or None, + _availability_only=True, + ) busy = [ { "calendar_id": event["calendar_id"], @@ -4600,11 +4700,14 @@ def recurrence_occurrence( if recurrence_start is None: raise CalendarError("Invalid recurrence_id") recurrence_key = normalized_recurrence_id(recurrence_id) - candidates = expand_event_occurrences( - master, - recurrence_start - timedelta(seconds=1), - recurrence_start + timedelta(seconds=1), - ) + try: + candidates = expand_event_occurrences( + master, + recurrence_start - timedelta(seconds=1), + recurrence_start + timedelta(seconds=1), + ) + except ICalendarError as exc: + raise CalendarError("The recurrence could not be verified within its limits; the occurrence was not changed.") from exc occurrence = next( ( item diff --git a/tests/test_ical.py b/tests/test_ical.py index 21eae75..560f3d5 100644 --- a/tests/test_ical.py +++ b/tests/test_ical.py @@ -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 diff --git a/tests/test_recurrence_preferences.py b/tests/test_recurrence_preferences.py index d44c07a..0e546e3 100644 --- a/tests/test_recurrence_preferences.py +++ b/tests/test_recurrence_preferences.py @@ -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: diff --git a/webui/package.json b/webui/package.json index 3170a58..264e6c8 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/calendar-webui", - "version": "0.1.23", + "version": "0.1.24", "private": true, "type": "module", "main": "src/index.ts",