From 66a6df4f05ca85de050c5458f6b45690ae3968ca Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 1 Aug 2026 17:48:23 +0200 Subject: [PATCH] feat: declare governed external provider state --- AGENTS.md | 6 + docs/CALENDAR_INTEGRATION_CONCEPT.md | 16 + src/govoplan_calendar/backend/manifest.py | 286 ++++++++++++++++++ .../backend/provider_state.py | 279 +++++++++++++++++ tests/test_provider_state.py | 174 +++++++++++ 5 files changed, 761 insertions(+) create mode 100644 src/govoplan_calendar/backend/provider_state.py create mode 100644 tests/test_provider_state.py diff --git a/AGENTS.md b/AGENTS.md index 0f5963a..0ac1a0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # GovOPlaN Calendar Codex Guide +## Documentation Contract + +- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior. +- Keep feature content here; `govoplan-docs` projects it without importing Calendar internals. +- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes. + ## Scope This repository owns the `calendar` module: calendar collections, VEVENT storage, iCalendar import/export, availability primitives, backend module manifest, and `@govoplan/calendar-webui`. diff --git a/docs/CALENDAR_INTEGRATION_CONCEPT.md b/docs/CALENDAR_INTEGRATION_CONCEPT.md index f42957e..19fd902 100644 --- a/docs/CALENDAR_INTEGRATION_CONCEPT.md +++ b/docs/CALENDAR_INTEGRATION_CONCEPT.md @@ -81,6 +81,22 @@ retirement before queued work is changed; a later retry tolerates a provider secret already removed by an earlier attempt whose database transaction rolled back. Provider errors and audit details never contain credential values or secret references. + +### Runtime provider state + +Calendar registers tenant-aware runtime state for +`calendar.caldav_sync`. Each active CalDAV source is projected by a stable +`calendar:sync-source:` reference with its effective source-authority mode, +enabled state, health, freshness, unresolved-conflict state, recovery readiness, +last successful synchronization, and bounded outbox counts. Collection URLs, +usernames, credential references, remote resource paths, and error text are not +included. + +Docs uses the projection to explain configured availability, Ops aggregates it +for operator inspection, and configuration-package preflight can require one +exact source binding. A source with dead or conflicting desired-state work +requires recovery attention; a never-synchronized source remains unknown rather +than being reported healthy. The current singular ownership model permits one active sync source per calendar; multi-source fan-in will require per-event source routing. Celery beat triggers recovery every minute, while root-transaction after-commit dispatch diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index fc53fb8..d64aea8 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -16,6 +16,7 @@ from govoplan_core.core.calendar import ( ) from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import ( + DocumentationTopic, FrontendModule, FrontendRoute, MigrationSpec, @@ -26,10 +27,225 @@ from govoplan_core.core.modules import ( PermissionDefinition, RoleTemplate, ) +from govoplan_core.core.provider_governance import ( + ExternalProviderDeclaration, + ExternalProviderStateProviderRegistration, + ModuleArchitectureDeclaration, + ModuleArchitectureDocumentation, + ModuleMaturityEvidence, + ProviderBehaviorDeclaration, + ProviderObjectDeclaration, +) from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base +CALDAV_PROVIDER_ID = "calendar.caldav_sync" +ICS_PROVIDER_ID = "calendar.ics_subscription" +GRAPH_PROVIDER_ID = "calendar.microsoft_graph" +EWS_PROVIDER_ID = "calendar.exchange_ews" + +CALENDAR_ARCHITECTURE = ModuleArchitectureDeclaration( + layer="communication_participation", + kind="domain", + maturity="vertical_slice", + evidence=( + ModuleMaturityEvidence( + kind="test", + reference="tests/test_caldav.py", + summary="Exercises CalDAV discovery, pull, push, deletion, and reconciliation behavior.", + ), + ModuleMaturityEvidence( + kind="test", + reference="tests/test_outbox.py", + summary="Exercises durable calendar effects, retries, terminal states, and cleanup.", + ), + ModuleMaturityEvidence( + kind="documentation", + reference="docs/CALENDAR_INTEGRATION_CONCEPT.md", + summary="Documents Calendar ownership and optional integration boundaries.", + ), + ), + known_limits=( + "Provider target-environment and recovery-drill evidence is not yet packaged as a reference package.", + "Microsoft Graph and EWS adapters do not yet have the same write/reconciliation coverage as CalDAV.", + ), + supported_authority_modes=( + "native_authoritative", + "external_authoritative", + "external_mirror", + "governed_sync", + "linked_reference", + ), + owned_concepts=( + "calendar collections", + "VEVENT lifecycle", + "recurrence and availability", + "calendar synchronization state", + ), + non_owned_concepts=( + "meeting polls", + "mail delivery", + "task lifecycle", + "external identity and credential secrets", + ), + target_tested_providers=(CALDAV_PROVIDER_ID,), + documentation=ModuleArchitectureDocumentation( + migration=("src/govoplan_calendar/backend/migrations/versions",), + upgrade=("docs/CALENDAR_INTEGRATION_CONCEPT.md",), + recovery=("docs/CALENDAR_INTEGRATION_CONCEPT.md",), + security=("tests/test_caldav_security.py", "tests/test_http_security.py"), + operations=("docs/CALENDAR_INTEGRATION_CONCEPT.md",), + ), +) + +def _read_only_calendar_provider( + *, + provider_id: str, + label: str, + source_name: str, +) -> ExternalProviderDeclaration: + return ExternalProviderDeclaration( + id=provider_id, + module_id="calendar", + label=label, + maturity="read", + operations=("read", "preview"), + objects=( + ProviderObjectDeclaration( + object_type="calendar_collection", + field_groups=("identity", "display", "sync_state"), + authority_modes=("external_authoritative", "external_mirror"), + default_authority_mode="external_mirror", + ), + ProviderObjectDeclaration( + object_type="calendar_event", + field_groups=( + "identity", + "schedule", + "recurrence", + "participants", + "content", + ), + authority_modes=("external_authoritative", "external_mirror"), + default_authority_mode="external_mirror", + ), + ), + behavior=ProviderBehaviorDeclaration( + revision_tokens=f"{source_name} object identity, remote revision, and content fingerprint are retained.", + concurrency="The source is inbound-only; refresh compares remote identity and revision before replacing the derived projection.", + freshness="Last attempt, last success, and source status are retained.", + health="Authentication, transport, parsing, and source failures are recorded without exposing credentials.", + max_read_items=5000, + evidence=f"Imported events retain the {source_name} source, remote identity, revision, and normalized content fingerprint.", + audit_event_types=("calendar.sync.requested", "calendar.sync.completed"), + correction="A later successful refresh creates the corrected local projection while source authority remains external.", + reconciliation="Re-read the bounded source and compare remote identity, revision, and normalized event content.", + outage="The last local projection remains available with stale or unknown freshness.", + classifications=("internal", "confidential", "restricted"), + purposes=("calendar subscription", "availability", "meeting coordination"), + retention="Calendar projection, sync evidence, and credential retention are independent policies.", + secret_handling="Authentication material remains in Calendar credential records or deployment-owned secret references.", + ), + documentation_topic_ids=("calendar.external-sources-and-sync",), + ) + + +CALENDAR_EXTERNAL_PROVIDERS = ( + ExternalProviderDeclaration( + id=CALDAV_PROVIDER_ID, + module_id="calendar", + label="CalDAV calendar synchronization", + maturity="synchronize", + operations=( + "discover", + "read", + "write", + "delete", + "synchronize", + "preview", + ), + objects=( + ProviderObjectDeclaration( + object_type="calendar_collection", + field_groups=("identity", "display", "sync_state"), + authority_modes=( + "native_authoritative", + "external_authoritative", + "external_mirror", + "governed_sync", + ), + default_authority_mode="governed_sync", + ), + ProviderObjectDeclaration( + object_type="calendar_event", + field_groups=( + "identity", + "schedule", + "recurrence", + "participants", + "content", + ), + authority_modes=( + "native_authoritative", + "external_authoritative", + "external_mirror", + "governed_sync", + ), + default_authority_mode="governed_sync", + ), + ), + behavior=ProviderBehaviorDeclaration( + revision_tokens="CalDAV sync tokens and per-resource ETags are retained.", + concurrency="Conditional requests reject stale ETags; conflicts remain explicit.", + freshness="Last attempt, last success, sync token, and pending outbox work are visible.", + health="Discovery, authentication, transport, collection, and reconciliation failures are recorded.", + max_read_items=5000, + idempotency="Stable VEVENT UID, source id, and durable operation keys suppress duplicate effects.", + retry="Only classified transient failures receive bounded backoff retries.", + timeout_seconds=30, + conflicts="Stale revisions and concurrent remote changes enter conflict/reconciliation state.", + outcome_unknown="A timed-out remote write is outcome-unknown and is not blindly repeated.", + outcome_unknown_supported=True, + evidence="Operation id, request intent, ETag/sync token, response classification, and reconciliation result are retained.", + audit_event_types=( + "calendar.sync.requested", + "calendar.sync.completed", + "calendar.sync.conflict", + "calendar.sync.reconciled", + ), + correction="A later conditional update or tombstone corrects external state after reconciliation.", + rollback="Remote effects are not treated as transactionally rollback-safe.", + compensation="A compensating event update/delete may be queued after the remote state is known.", + reconciliation="Read the resource by href/UID, compare ETag and content, then classify applied, retryable, conflict, or manual.", + outage="Local calendars remain usable with stale markers; pending writes stay durable and bounded.", + classifications=("internal", "confidential", "restricted"), + purposes=("calendar collaboration", "availability", "meeting coordination"), + retention="Calendar event, outbox terminal-state, audit, and credential retention are independent policies.", + secret_handling="Authentication material is stored through Calendar credential records or external secret references and is never emitted in provider metadata.", + ), + capability_names=(CAPABILITY_CALENDAR_OUTBOX,), + interface_names=("calendar.outbox",), + documentation_topic_ids=("calendar.external-sources-and-sync",), + ), + _read_only_calendar_provider( + provider_id=ICS_PROVIDER_ID, + label="ICS and webcal subscription", + source_name="ICS/webcal", + ), + _read_only_calendar_provider( + provider_id=GRAPH_PROVIDER_ID, + label="Microsoft Graph calendar projection", + source_name="Microsoft Graph", + ), + _read_only_calendar_provider( + provider_id=EWS_PROVIDER_ID, + label="Exchange Web Services calendar projection", + source_name="Exchange Web Services", + ), +) + + _calendar_table_retirement_provider = drop_table_retirement_provider( calendar_models.CalendarCollection, calendar_models.CalendarEvent, @@ -185,6 +401,30 @@ def _calendar_outbox_provider(context: ModuleContext) -> object: ) +def _caldav_provider_states(context): + from govoplan_calendar.backend.provider_state import caldav_provider_states + + return caldav_provider_states(context) + + +def _ics_provider_states(context): + from govoplan_calendar.backend.provider_state import ics_provider_states + + return ics_provider_states(context) + + +def _graph_provider_states(context): + from govoplan_calendar.backend.provider_state import graph_provider_states + + return graph_provider_states(context) + + +def _ews_provider_states(context): + from govoplan_calendar.backend.provider_state import ews_provider_states + + return ews_provider_states(context) + + manifest = ModuleManifest( id="calendar", name="Calendar", @@ -200,6 +440,52 @@ manifest = ModuleManifest( route_factory=_calendar_router, role_templates=ROLE_TEMPLATES, tenant_summary_providers=(_tenant_summary,), + architecture=CALENDAR_ARCHITECTURE, + external_providers=CALENDAR_EXTERNAL_PROVIDERS, + external_provider_state_providers=( + ExternalProviderStateProviderRegistration( + module_id="calendar", + provider_id=CALDAV_PROVIDER_ID, + provider=_caldav_provider_states, + ), + ExternalProviderStateProviderRegistration( + module_id="calendar", + provider_id=ICS_PROVIDER_ID, + provider=_ics_provider_states, + ), + ExternalProviderStateProviderRegistration( + module_id="calendar", + provider_id=GRAPH_PROVIDER_ID, + provider=_graph_provider_states, + ), + ExternalProviderStateProviderRegistration( + module_id="calendar", + provider_id=EWS_PROVIDER_ID, + provider=_ews_provider_states, + ), + ), + documentation=( + DocumentationTopic( + id="calendar.manage-calendars-and-events", + title="Use calendars and events", + summary="Create calendar collections and work with all-day or timed events in continuous, month, week, workweek, and day views.", + body="Calendar remembers the selected view and preferences. Events can be created, edited, moved, resized, repeated, imported, exported, or deleted when the current account has the corresponding permission. All-day events use dates rather than local clock times; timed events retain their timezone-aware start and end values.", + documentation_types=("user",), + audience=("user", "calendar_manager"), + related_modules=("scheduling", "notifications"), + metadata={"kind": "reference"}, + ), + DocumentationTopic( + id="calendar.external-sources-and-sync", + title="Connect and synchronize external calendars", + summary="Calendar supports local collections, two-way CalDAV, and read-only ICS/webcal, Microsoft Graph, and Exchange Web Services sources.", + body="Each external source keeps its URL, synchronization direction, status, and credential reference with the Calendar collection. Manual or scheduled synchronization records bounded outcomes. CalDAV writes use conditional requests and durable outbox state; conflicts and unknown outcomes require synchronization or explicit reconciliation instead of blind repetition. Removing an external source removes the connection, while deleting a local calendar deletes its owned events after confirmation or transfer.", + documentation_types=("admin", "user"), + audience=("user", "calendar_manager", "operator"), + related_modules=("connectors", "audit", "ops"), + metadata={"kind": "reference"}, + ), + ), capability_factories={ CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider, CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider, diff --git a/src/govoplan_calendar/backend/provider_state.py b/src/govoplan_calendar/backend/provider_state.py new file mode 100644 index 0000000..5cafa4d --- /dev/null +++ b/src/govoplan_calendar/backend/provider_state.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from govoplan_calendar.backend.db.models import ( + CalendarOutboxOperation, + CalendarSyncSource, +) +from govoplan_core.core.provider_governance import ( + ExternalProviderRuntimeState, + ExternalProviderStateContext, +) + + +CALDAV_PROVIDER_ID = "calendar.caldav_sync" +ICS_PROVIDER_ID = "calendar.ics_subscription" +GRAPH_PROVIDER_ID = "calendar.microsoft_graph" +EWS_PROVIDER_ID = "calendar.exchange_ews" +_HEALTHY_STATUSES = frozenset({"ok", "outbound_ok"}) +_ERROR_STATUSES = frozenset({"error", "outbound_error"}) +_WARNING_STATUSES = frozenset( + {"outbound_retry", "outbound_cancelled", "outbound_discarded"} +) +_ACTIVE_OUTBOX_STATUSES = frozenset({"pending", "retry", "in_progress"}) + + +def caldav_provider_states( + context: ExternalProviderStateContext, +) -> tuple[ExternalProviderRuntimeState, ...]: + return _provider_states( + context, + provider_id=CALDAV_PROVIDER_ID, + source_kinds=("caldav",), + include_outbox=True, + ) + + +def ics_provider_states( + context: ExternalProviderStateContext, +) -> tuple[ExternalProviderRuntimeState, ...]: + return _provider_states( + context, + provider_id=ICS_PROVIDER_ID, + source_kinds=("ics", "webcal"), + ) + + +def graph_provider_states( + context: ExternalProviderStateContext, +) -> tuple[ExternalProviderRuntimeState, ...]: + return _provider_states( + context, + provider_id=GRAPH_PROVIDER_ID, + source_kinds=("graph",), + ) + + +def ews_provider_states( + context: ExternalProviderStateContext, +) -> tuple[ExternalProviderRuntimeState, ...]: + return _provider_states( + context, + provider_id=EWS_PROVIDER_ID, + source_kinds=("ews",), + ) + + +def _provider_states( + context: ExternalProviderStateContext, + *, + provider_id: str, + source_kinds: tuple[str, ...], + include_outbox: bool = False, +) -> tuple[ExternalProviderRuntimeState, ...]: + if not isinstance(context.session, Session): + raise RuntimeError("Calendar provider state requires a database session.") + statement = select(CalendarSyncSource).where( + CalendarSyncSource.source_kind.in_(source_kinds), + CalendarSyncSource.deleted_at.is_(None), + ) + if context.tenant_id is not None: + statement = statement.where( + CalendarSyncSource.tenant_id == context.tenant_id + ) + sources = tuple( + context.session.scalars( + statement.order_by( + CalendarSyncSource.tenant_id, + CalendarSyncSource.id, + ).limit(context.max_items + 1) + ) + ) + if not sources: + return () + + counts = ( + _outbox_counts(context.session, tuple(item.id for item in sources)) + if include_outbox + else {} + ) + observed_at = datetime.now(UTC) + return tuple( + _source_state( + source, + provider_id=provider_id, + observed_at=observed_at, + outbox_counts=counts.get(source.id, {}), + ) + for source in sources + ) + + +def _outbox_counts( + session: Session, + source_ids: tuple[str, ...], +) -> dict[str, dict[str, int]]: + result: dict[str, dict[str, int]] = defaultdict(dict) + if not source_ids: + return result + rows = session.execute( + select( + CalendarOutboxOperation.source_id, + CalendarOutboxOperation.status, + func.count(CalendarOutboxOperation.id), + ) + .where(CalendarOutboxOperation.source_id.in_(source_ids)) + .group_by( + CalendarOutboxOperation.source_id, + CalendarOutboxOperation.status, + ) + ) + for source_id, status, count in rows: + result[str(source_id)][str(status)] = int(count) + return result + + +def _source_state( + source: CalendarSyncSource, + *, + provider_id: str, + observed_at: datetime, + outbox_counts: dict[str, int], +) -> ExternalProviderRuntimeState: + active = bool(source.sync_enabled) + conflict_count = int(outbox_counts.get("conflict", 0)) + dead_count = int(outbox_counts.get("dead", 0)) + pending_count = sum( + int(outbox_counts.get(status, 0)) + for status in _ACTIVE_OUTBOX_STATUSES + ) + health = _health_state( + active=active, + last_status=source.last_status, + conflict_count=conflict_count, + dead_count=dead_count, + pending_count=pending_count, + ) + conflict = ( + "blocked" + if dead_count + else "pending" + if conflict_count + else "clear" + ) + freshness = _freshness_state(source, observed_at=observed_at) + recovery = ( + "not_applicable" + if not active + else "attention" + if conflict_count or dead_count or health == "error" + else "ready" + ) + return ExternalProviderRuntimeState( + provider_id=provider_id, + binding_ref=f"calendar:sync-source:{source.id}", + authority_mode=( + "governed_sync" + if source.source_kind == "caldav" and source.sync_direction == "two_way" + else "external_mirror" + ), + observed_at=observed_at, + configured=True, + active=active, + health=health, + freshness=freshness, + conflict=conflict, + recovery=recovery, + last_success_at=_aware(source.last_synced_at), + detail=_state_detail( + active=active, + health=health, + freshness=freshness, + conflict=conflict, + ), + metrics={ + "pending_operations": pending_count, + "conflict_operations": conflict_count, + "dead_operations": dead_count, + "sync_interval_seconds": source.sync_interval_seconds, + }, + ) + + +def _health_state( + *, + active: bool, + last_status: str | None, + conflict_count: int, + dead_count: int, + pending_count: int, +) -> str: + if not active: + return "inactive" + if dead_count or last_status in _ERROR_STATUSES: + return "error" + if conflict_count or last_status in _WARNING_STATUSES: + return "warning" + if last_status in _HEALTHY_STATUSES: + return "healthy" + if pending_count: + return "warning" + return "unknown" + + +def _freshness_state( + source: CalendarSyncSource, + *, + observed_at: datetime, +) -> str: + if not source.sync_enabled: + return "not_applicable" + last_synced_at = _aware(source.last_synced_at) + if last_synced_at is None: + return "unknown" + interval = max(int(source.sync_interval_seconds or 0), 60) + grace = timedelta(seconds=max(interval * 2, 1_800)) + return "current" if observed_at - last_synced_at <= grace else "stale" + + +def _state_detail( + *, + active: bool, + health: str, + freshness: str, + conflict: str, +) -> str: + if not active: + return "Synchronization is configured but disabled." + if conflict != "clear": + return "Synchronization has unresolved external outcomes." + if health == "error": + return "The latest synchronization attempt failed." + if freshness == "stale": + return "The last successful synchronization is stale." + if health == "healthy": + return "Synchronization is healthy." + return "Synchronization has not produced a conclusive health observation." + + +def _aware(value: datetime | None) -> datetime | None: + if value is None: + return None + return value.replace(tzinfo=UTC) if value.tzinfo is None else value + + +__all__ = [ + "CALDAV_PROVIDER_ID", + "EWS_PROVIDER_ID", + "GRAPH_PROVIDER_ID", + "ICS_PROVIDER_ID", + "caldav_provider_states", + "ews_provider_states", + "graph_provider_states", + "ics_provider_states", +] diff --git a/tests/test_provider_state.py b/tests/test_provider_state.py new file mode 100644 index 0000000..e037b18 --- /dev/null +++ b/tests/test_provider_state.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_access.backend.db import models as access_models # noqa: F401 +from govoplan_calendar.backend.db.models import CalendarOutboxOperation, CalendarSyncSource +from govoplan_calendar.backend.manifest import ( + CALDAV_PROVIDER_ID, + EWS_PROVIDER_ID, + GRAPH_PROVIDER_ID, + ICS_PROVIDER_ID, + manifest, +) +from govoplan_calendar.backend.provider_state import ( + caldav_provider_states, + ews_provider_states, + graph_provider_states, + ics_provider_states, +) +from govoplan_calendar.backend.schemas import ( + CalendarCalDavSourceCreateRequest, + CalendarCollectionCreateRequest, +) +from govoplan_calendar.backend.service import create_caldav_source, create_calendar +from govoplan_core.core.provider_governance import ExternalProviderStateContext +from govoplan_core.db.base import Base +from govoplan_core.tenancy.scope import create_scope_tables +from govoplan_tenancy.backend.db.models import Tenant + + +class CalendarProviderStateTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + create_scope_tables(self.engine) + Base.metadata.create_all(self.engine) + self.Session = sessionmaker(bind=self.engine, expire_on_commit=False) + self.session = self.Session() + self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + self.calendar = create_calendar( + self.session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCollectionCreateRequest(name="Remote"), + ) + self.source = create_caldav_source( + self.session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest( + calendar_id=self.calendar.id, + collection_url="https://dav.example.test/cal", + ), + ) + self.session.commit() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_state_projects_health_freshness_and_unresolved_outcomes(self) -> None: + self.source.last_status = "ok" + self.source.last_synced_at = datetime.now(UTC) + self.session.flush() + + healthy = caldav_provider_states( + ExternalProviderStateContext( + session=self.session, + tenant_id="tenant-1", + ) + )[0] + + self.assertEqual("healthy", healthy.health) + self.assertEqual("current", healthy.freshness) + self.assertEqual("clear", healthy.conflict) + self.assertEqual("ready", healthy.recovery) + self.assertEqual( + f"calendar:sync-source:{self.source.id}", + healthy.binding_ref, + ) + self.assertNotIn("dav.example.test", str(healthy.to_dict())) + + self.session.add( + CalendarOutboxOperation( + tenant_id="tenant-1", + source_id=self.source.id, + operation_kind="put", + resource_href="event.ics", + idempotency_key="a" * 64, + status="conflict", + ) + ) + self.session.flush() + + conflicted = caldav_provider_states( + ExternalProviderStateContext( + session=self.session, + tenant_id="tenant-1", + ) + )[0] + + self.assertEqual("warning", conflicted.health) + self.assertEqual("pending", conflicted.conflict) + self.assertEqual("attention", conflicted.recovery) + + def test_state_is_tenant_bounded_and_manifest_registered(self) -> None: + self.assertEqual( + (), + caldav_provider_states( + ExternalProviderStateContext( + session=self.session, + tenant_id="tenant-2", + ) + ), + ) + self.assertEqual( + {CALDAV_PROVIDER_ID, ICS_PROVIDER_ID, GRAPH_PROVIDER_ID, EWS_PROVIDER_ID}, + {item.id for item in manifest.external_providers}, + ) + self.assertEqual( + {CALDAV_PROVIDER_ID, ICS_PROVIDER_ID, GRAPH_PROVIDER_ID, EWS_PROVIDER_ID}, + { + item.provider_id + for item in manifest.external_provider_state_providers + }, + ) + + def test_read_only_sources_have_distinct_secret_free_provider_state(self) -> None: + now = datetime.now(UTC) + sources = ( + ("ics", "https://feeds.example.test/team.ics", "none"), + ("graph", "https://graph.microsoft.com/v1.0/me/calendar/events", "bearer"), + ("ews", "https://exchange.example.test/EWS/Exchange.asmx", "basic"), + ) + for source_kind, collection_url, auth_type in sources: + self.session.add( + CalendarSyncSource( + tenant_id="tenant-1", + calendar_id=self.calendar.id, + source_kind=source_kind, + collection_url=collection_url, + auth_type=auth_type, + sync_enabled=True, + sync_direction="read_only", + last_status="ok", + last_synced_at=now, + ) + ) + self.session.flush() + + states = ( + ics_provider_states( + ExternalProviderStateContext(session=self.session, tenant_id="tenant-1") + )[0], + graph_provider_states( + ExternalProviderStateContext(session=self.session, tenant_id="tenant-1") + )[0], + ews_provider_states( + ExternalProviderStateContext(session=self.session, tenant_id="tenant-1") + )[0], + ) + + self.assertTrue(all(item.authority_mode == "external_mirror" for item in states)) + self.assertTrue(all(item.health == "healthy" for item in states)) + rendered = str([item.to_dict() for item in states]) + self.assertNotIn("feeds.example.test", rendered) + self.assertNotIn("exchange.example.test", rendered) + + +if __name__ == "__main__": + unittest.main()