From 2cb86c90dcb97a0a32781ba64f73b43520ff0c2c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 03:08:56 +0200 Subject: [PATCH] feat(scheduling): add explicit invitation link lifecycle --- README.md | 36 ++- src/govoplan_scheduling/backend/router.py | 172 +++++++++++- src/govoplan_scheduling/backend/schemas.py | 36 ++- src/govoplan_scheduling/backend/service.py | 299 +++++++++++++++----- tests/test_response_editing.py | 52 +++- tests/test_service.py | 312 +++++++++++++++++++-- 6 files changed, 785 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 4b845e1..21b4a20 100644 --- a/README.md +++ b/README.md @@ -179,16 +179,32 @@ frontend handoff rather than a working guest page. The token is never moved into query parameters, browser storage, or an authenticated API contract while that shell boundary is unresolved. -Draft saves never issue public tokens or enqueue invitation delivery, even when -`create_participant_invitations` is left at its compatibility default. A -collecting request may explicitly issue invitations; authenticated in-module -responses lazily create a gateway-bound invitation and discard its token. The -draft-to-open transition therefore supports authenticated lazy responses, but -does not make a guest link available from the current UI. The product decision -still open is the explicit organizer workflow for issuing or reissuing public -links after a draft is opened and, when Mail is installed, whether that action -should also enqueue delivery or return links for separate distribution. Until -that workflow is agreed, opening a draft does not silently send anything. +Creating, editing, and opening a request never issue public tokens or enqueue +invitation delivery. The deprecated `create_participant_invitations` request +field remains accepted for compatibility, defaults to `false`, and has no side +effect. Authenticated in-module responses can still lazily create a +gateway-bound invitation whose token is discarded. + +Organizers and Scheduling administrators use the participant-specific +invitation action instead: + +- `POST /scheduling/requests/{request_id}/participants/{participant_id}/invitation` + with `{"action":"copy"}` rotates the previous invitation and returns the new + relative action URL once. The response is marked `no-store`. +- The same endpoint with `{"action":"send"}` rotates the invitation and passes + its URL directly to the notification dispatch job. Neither the API response + nor Scheduling's durable notification projection contains the token. +- `DELETE` on the same resource revokes the active link immediately; exact + repeats are idempotent. + +Links can be issued in any request state. Collection state and deadline checks +remain independent submission requirements, so a link to a draft, closed, or +decided request is read-only. Issue, copy, send-request, and revoke actions are +audited with request and participant identifiers but never a bearer token. +Only an organizer (under the ordinary Scheduling write policy) or a tenant-wide +Scheduling administrator can use these actions. If notification delivery is +not installed, `send` fails before rotating the current link and the organizer +can use `copy` for separate distribution. ## FieldLabel omission register diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 80114dd..44d7189 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -3,9 +3,10 @@ from __future__ import annotations import dataclasses from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from sqlalchemy.orm import Session +from govoplan_core.audit.logging import audit_event from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE from govoplan_core.db.session import get_session @@ -18,6 +19,8 @@ from govoplan_scheduling.backend.schemas import ( SchedulingCalendarActionResponse, SchedulingCandidateSlotUpdateRequest, SchedulingDecisionRequest, + SchedulingInvitationActionRequest, + SchedulingInvitationActionResponse, SchedulingNotificationCreateRequest, SchedulingNotificationListResponse, SchedulingNotificationResponse, @@ -52,9 +55,11 @@ from govoplan_scheduling.backend.service import ( get_visible_scheduling_request, list_visible_scheduling_notifications, list_visible_scheduling_requests, + issue_scheduling_participant_invitation, open_scheduling_request, refresh_participant_response_state, require_visible_scheduling_results, + revoke_scheduling_participant_invitation, scheduling_notification_response, scheduling_request_response, scheduling_request_summary, @@ -186,16 +191,41 @@ def _client_address(request: Request) -> str | None: return request.client.host if request.client is not None else None +def _set_sensitive_response_headers(response: Response) -> None: + response.headers["Cache-Control"] = "no-store, private" + response.headers["Pragma"] = "no-cache" + response.headers["Referrer-Policy"] = "no-referrer" + + +def _audit_invitation_action( + session: Session, + *, + principal: ApiPrincipal, + request_id: str, + participant_id: str, + action: str, + details: dict[str, Any], +) -> None: + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=(getattr(principal.user, "id", None) or principal.account_id), + api_key_id=principal.api_key_id, + action=action, + object_type="scheduling_request", + object_id=request_id, + details={"participant_id": participant_id, **details}, + ) + + def _request_response( request, *, principal: ApiPrincipal, - invitation_tokens: dict[str, str] | None = None, ) -> SchedulingRequestResponse: return SchedulingRequestResponse.model_validate( scheduling_request_response( request, - invitation_tokens=invitation_tokens, actor_ids=_principal_actor_ids(principal), actor_user_id=principal.account_id, can_manage=_can_manage_scheduling(principal), @@ -311,7 +341,7 @@ def api_create_scheduling_request( ) -> SchedulingRequestResponse: _require_scheduling_writer(principal) try: - request, invitation_tokens = create_scheduling_request( + request, _invitation_tokens = create_scheduling_request( session, tenant_id=principal.tenant_id, user_id=principal.account_id, @@ -319,7 +349,7 @@ def api_create_scheduling_request( ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - response = _request_response(request, principal=principal, invitation_tokens=invitation_tokens) + response = _request_response(request, principal=principal) session.commit() return response @@ -411,7 +441,7 @@ def api_update_scheduling_request( request_id=request_id, ) try: - request, invitation_tokens = update_scheduling_request_with_invitation_tokens( + request, _invitation_tokens = update_scheduling_request_with_invitation_tokens( session, tenant_id=principal.tenant_id, request_id=request_id, @@ -419,15 +449,135 @@ def api_update_scheduling_request( ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - response = _request_response( - request, - principal=principal, - invitation_tokens=invitation_tokens, - ) + response = _request_response(request, principal=principal) session.commit() return response +@router.post( + "/requests/{request_id}/participants/{participant_id}/invitation", + response_model=SchedulingInvitationActionResponse, +) +def api_issue_scheduling_participant_invitation( + request_id: str, + participant_id: str, + payload: SchedulingInvitationActionRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> SchedulingInvitationActionResponse: + _require_request_editor( + session, + principal=principal, + request_id=request_id, + ) + try: + result = issue_scheduling_participant_invitation( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + participant_id=participant_id, + action=payload.action, + ) + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + audit_details = { + "request_status": result.request.status, + "delivery_action": payload.action, + "replaced_existing": result.replaced_existing, + } + _audit_invitation_action( + session, + principal=principal, + request_id=request_id, + participant_id=participant_id, + action="scheduling.invitation_issued", + details=audit_details, + ) + _audit_invitation_action( + session, + principal=principal, + request_id=request_id, + participant_id=participant_id, + action=( + "scheduling.invitation_copied" + if payload.action == "copy" + else "scheduling.invitation_send_requested" + ), + details={ + **audit_details, + "notification_id": ( + result.notification.id if result.notification is not None else None + ), + "notification_status": result.status, + }, + ) + validated = SchedulingInvitationActionResponse( + participant_id=result.participant.id, + action=payload.action, + status=result.status, + action_url=result.action_url, + issued_at=result.participant.last_invited_at, + notification=( + SchedulingNotificationResponse.model_validate( + scheduling_notification_response(result.notification) + ) + if result.notification is not None + else None + ), + ) + _set_sensitive_response_headers(response) + session.commit() + return validated + + +@router.delete( + "/requests/{request_id}/participants/{participant_id}/invitation", + response_model=SchedulingInvitationActionResponse, +) +def api_revoke_scheduling_participant_invitation( + request_id: str, + participant_id: str, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> SchedulingInvitationActionResponse: + _require_request_editor( + session, + principal=principal, + request_id=request_id, + ) + try: + result = revoke_scheduling_participant_invitation( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + participant_id=participant_id, + ) + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + _audit_invitation_action( + session, + principal=principal, + request_id=request_id, + participant_id=participant_id, + action="scheduling.invitation_revoked", + details={ + "request_status": result.request.status, + "replayed": result.replayed, + }, + ) + validated = SchedulingInvitationActionResponse( + participant_id=result.participant.id, + action="revoke", + status=result.status, + replayed=result.replayed, + ) + _set_sensitive_response_headers(response) + session.commit() + return validated + + @router.patch("/requests/{request_id}/slots/{slot_id}", response_model=SchedulingRequestResponse) def api_update_scheduling_candidate_slot( request_id: str, diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index 7a95b57..74fd932 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -158,7 +158,14 @@ class SchedulingRequestCreateRequest(BaseModel): calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences) slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1) participants: list[SchedulingParticipantInput] = Field(default_factory=list) - create_participant_invitations: bool = True + create_participant_invitations: bool = Field( + default=False, + deprecated=True, + description=( + "Compatibility field; participant links are issued only through " + "the explicit participant invitation action." + ), + ) metadata: dict[str, Any] = Field(default_factory=dict) _validate_timezone = field_validator("timezone")(_known_timezone) @@ -197,7 +204,14 @@ class SchedulingRequestUpdateRequest(BaseModel): min_length=1, ) participants: list[SchedulingParticipantReconcileInput] | None = None - create_participant_invitations: bool = True + create_participant_invitations: bool = Field( + default=False, + deprecated=True, + description=( + "Compatibility field; participant links are issued only through " + "the explicit participant invitation action." + ), + ) metadata: dict[str, Any] | None = None @model_validator(mode="after") @@ -483,6 +497,24 @@ class SchedulingNotificationCreateRequest(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) +class SchedulingInvitationActionRequest(BaseModel): + """Explicitly issue one fresh participant-specific participation link.""" + + model_config = ConfigDict(extra="forbid") + + action: Literal["copy", "send"] + + +class SchedulingInvitationActionResponse(BaseModel): + participant_id: str + action: Literal["copy", "send", "revoke"] + status: str + action_url: str | None = None + issued_at: datetime | None = None + replayed: bool = False + notification: SchedulingNotificationResponse | None = None + + class SchedulingAddressLookupCandidate(BaseModel): contact_id: str address_book_id: str diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 9a14101..4620c26 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -87,6 +87,18 @@ class SchedulingPublicParticipationError(SchedulingError): self.retry_after_seconds = max(0, retry_after_seconds) +@dataclass(frozen=True, slots=True) +class SchedulingInvitationActionResult: + request: SchedulingRequest + participant: SchedulingParticipant + action: str + status: str + action_url: str | None = None + notification: SchedulingNotification | None = None + replaced_existing: bool = False + replayed: bool = False + + WORKFLOW_DRAFT = "draft" WORKFLOW_COLLECTING = "collecting_availability" WORKFLOW_CLOSED = "availability_closed" @@ -626,13 +638,30 @@ def create_scheduling_notification_jobs( channel: str = "mail", metadata: dict[str, Any] | None = None, invitation_tokens: dict[str, str] | None = None, + participant_ids: frozenset[str] | None = None, ) -> list[SchedulingNotification]: if event_kind not in NOTIFICATION_KINDS: raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}") request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + participants = [ + participant + for participant in _active_participants(request) + if participant_ids is None or participant.id in participant_ids + ] + if participant_ids is not None and { + participant.id for participant in participants + } != set(participant_ids): + raise SchedulingError("Scheduling participant not found") + if event_kind == "invitation" and ( + not invitation_tokens + or any(participant.id not in invitation_tokens for participant in participants) + ): + raise SchedulingError( + "Invitation notifications require a freshly issued participation link" + ) notifications: list[SchedulingNotification] = [] base_metadata = metadata or {} - for participant in _active_participants(request): + for participant in participants: recipient = participant.email or participant.respondent_id status = "pending" if recipient else "skipped" notification = SchedulingNotification( @@ -694,9 +723,12 @@ def _mirror_scheduling_notifications_to_center( ), enqueue_delivery=True, ) - except Exception as exc: # noqa: BLE001 - mirroring must not block scheduling workflow. + except Exception: # noqa: BLE001 - mirroring must not block scheduling workflow. notification.status = "failed" - notification.error = f"Notification center enqueue failed: {exc}" + # A provider exception can include a representation of the dispatch + # request. Invitation requests contain a bearer link, so durable + # error text must never copy exception details. + notification.error = "Notification center enqueue failed" continue notification.metadata_ = { **(notification.metadata_ or {}), @@ -707,7 +739,7 @@ def _mirror_scheduling_notifications_to_center( notification.status = str(response["status"]) elif response.get("status") == "skipped": notification.status = "skipped" - notification.error = str(response.get("last_error") or "Notification center skipped delivery") + notification.error = "Notification center skipped delivery" session.flush() @@ -1070,12 +1102,6 @@ def create_scheduling_request( user_id: str | None, payload: SchedulingRequestCreateRequest, ) -> tuple[SchedulingRequest, dict[str, str]]: - participation_provider = _poll_participation_provider() - issue_public_invitations = ( - payload.create_participant_invitations and payload.status != "draft" - ) - if issue_public_invitations and participation_provider is None: - raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON) if not payload.allow_external_participants: for participant_input in payload.participants: if participant_input.participant_type == "external": @@ -1135,7 +1161,7 @@ def create_scheduling_request( email=participant_input.email, participant_type=participant_input.participant_type, required=participant_input.required, - status="invited" if issue_public_invitations else "draft", + status="draft", metadata_=participant_input.metadata, ) session.add(participant) @@ -1186,47 +1212,11 @@ def create_scheduling_request( for slot in _active_slots(request): slot.poll_option_id = options_by_position.get(slot.position) - invitation_tokens: dict[str, str] = {} - if issue_public_invitations: - if participation_provider is None: # Guarded above; narrows the type. - raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON) - for participant in _active_participants(request): - try: - respondent_id = _stable_participant_respondent_id(participant) - invitation = participation_provider.create_governed_invitation( - session, - tenant_id=tenant_id, - poll_id=poll.id, - command=PollGovernedInvitationCommand( - gateway=_public_participation_gateway(request.id), - policy=_public_participation_policy(request), - respondent_id=respondent_id, - respondent_label=participant.display_name, - email=participant.email, - expires_at=payload.deadline_at, - metadata={ - "scheduling_request_id": request.id, - "scheduling_participant_id": participant.id, - }, - ), - ) - participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY - except PollCapabilityError as exc: - raise SchedulingError(str(exc)) from exc - participant.poll_invitation_id = invitation.id - participant.last_invited_at = _now() - participant.status = "invited" - invitation_tokens[participant.id] = invitation.token - create_scheduling_notification_jobs( - session, - tenant_id=tenant_id, - request_id=request.id, - event_kind="invitation", - metadata={"created_with_request": True}, - invitation_tokens=invitation_tokens, - ) session.flush() - return request, invitation_tokens + # The deprecated create_participant_invitations field is deliberately a + # no-op. Bearer credentials are minted only by the explicit participant + # invitation action, where copy and delivery have distinct handling. + return request, {} def list_scheduling_requests(session: Session, *, tenant_id: str, status: str | None = None) -> list[SchedulingRequest]: @@ -2134,10 +2124,6 @@ def _update_scheduling_request_with_invitation_tokens( session, request=request, supplied_participants=payload.participants, - create_invitations=( - payload.create_participant_invitations - and request.status != "draft" - ), ) if deadline_changed: for participant in _active_participants(request): @@ -2218,7 +2204,7 @@ def update_scheduling_request_with_invitation_tokens( request_id: str, payload: SchedulingRequestUpdateRequest, ) -> tuple[SchedulingRequest, dict[str, str]]: - """Update a request and return any one-time tokens issued for new rows.""" + """Compatibility wrapper; explicit invitation actions own raw tokens.""" return _update_scheduling_request_with_invitation_tokens( session, @@ -2612,19 +2598,102 @@ def _validate_participant_reconciliation( ) -def _create_reconciled_participant_invitation( +def _participant_for_invitation_action( + request: SchedulingRequest, + *, + participant_id: str, +) -> SchedulingParticipant: + participant = next( + ( + item + for item in _active_participants(request) + if item.id == participant_id + ), + None, + ) + if participant is None: + raise SchedulingError("Scheduling participant not found") + return participant + + +def _new_public_invitation_expiry( + request: SchedulingRequest, +) -> datetime | None: + """Keep collecting links deadline-bound without minting expired tokens. + + Read-only links can still be issued after a response deadline or in a later + lifecycle state; Scheduling independently rejects submissions whenever the + request is not collecting. Cancellation can impose its own bounded notice + expiry on top of this lifecycle helper. + """ + + deadline = response_datetime(request.deadline_at) + return deadline if deadline is not None and deadline > _now() else None + + +def _participant_has_delivery_target( + participant: SchedulingParticipant, +) -> bool: + if participant.email: + return True + respondent_id = (participant.respondent_id or "").strip() + return bool( + respondent_id + and not respondent_id.startswith("scheduling-participant:") + ) + + +def issue_scheduling_participant_invitation( session: Session, *, - request: SchedulingRequest, - participant: SchedulingParticipant, -) -> str: + tenant_id: str, + request_id: str, + participant_id: str, + action: str, +) -> SchedulingInvitationActionResult: + """Rotate one bearer link for explicit copy or immediate delivery. + + The returned result contains the bearer URL only for ``copy``. ``send`` + passes it directly into the notification dispatch boundary and returns only + the durable job projection. + """ + + if action not in {"copy", "send"}: + raise SchedulingError("Unsupported scheduling invitation action") + request = _lock_scheduling_request( + session, + tenant_id=tenant_id, + request_id=request_id, + lock_participants=True, + ) + participant = _participant_for_invitation_action( + request, + participant_id=participant_id, + ) if request.poll_id is None: - raise SchedulingError("Scheduling request has no backing poll") + raise SchedulingError("Scheduling invitation action is unavailable") participation_provider = _poll_participation_provider() if participation_provider is None: raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON) + if action == "send": + if not _participant_has_delivery_target(participant): + raise SchedulingError( + "Participant has no deliverable email address or account" + ) + if notification_dispatch_provider(get_registry()) is None: + raise SchedulingError( + "Notification delivery is unavailable; copy the link instead" + ) + + previous_invitation_id = participant.poll_invitation_id try: - respondent_id = _stable_participant_respondent_id(participant) + if previous_invitation_id is not None: + participation_provider.revoke_invitation( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + invitation_id=previous_invitation_id, + ) invitation = participation_provider.create_governed_invitation( session, tenant_id=request.tenant_id, @@ -2632,23 +2701,110 @@ def _create_reconciled_participant_invitation( command=PollGovernedInvitationCommand( gateway=_public_participation_gateway(request.id), policy=_public_participation_policy(request), - respondent_id=respondent_id, + respondent_id=_stable_participant_respondent_id(participant), respondent_label=participant.display_name, email=participant.email, - expires_at=request.deadline_at, + expires_at=_new_public_invitation_expiry(request), metadata={ "scheduling_request_id": request.id, "scheduling_participant_id": participant.id, + "public_link_issued": True, + "issue_action": action, }, ), ) - participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY except PollCapabilityError as exc: - raise SchedulingError(str(exc)) from exc + # Poll deliberately keeps its external participation errors generic; + # retain that property at Scheduling's management boundary too. + raise SchedulingError( + "Scheduling invitation action could not be completed" + ) from exc + participant.poll_invitation_id = invitation.id + participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY participant.last_invited_at = _now() - participant.status = "invited" - return invitation.token + if participant.status in {"draft", "invited"}: + participant.status = "invited" + + action_url = f"/scheduling/public/{request.id}/{invitation.token}" + notification: SchedulingNotification | None = None + result_status = "issued" + if action == "send": + notifications = create_scheduling_notification_jobs( + session, + tenant_id=request.tenant_id, + request_id=request.id, + event_kind="invitation", + metadata={"explicit_participant_delivery": True}, + invitation_tokens={participant.id: invitation.token}, + participant_ids=frozenset({participant.id}), + ) + notification = notifications[0] + result_status = notification.status + action_url = None + session.flush() + return SchedulingInvitationActionResult( + request=request, + participant=participant, + action=action, + status=result_status, + action_url=action_url, + notification=notification, + replaced_existing=previous_invitation_id is not None, + ) + + +def revoke_scheduling_participant_invitation( + session: Session, + *, + tenant_id: str, + request_id: str, + participant_id: str, +) -> SchedulingInvitationActionResult: + """Immediately revoke the current link; exact repeats are safe no-ops.""" + + request = _lock_scheduling_request( + session, + tenant_id=tenant_id, + request_id=request_id, + lock_participants=True, + ) + participant = _participant_for_invitation_action( + request, + participant_id=participant_id, + ) + invitation_id = participant.poll_invitation_id + replayed = invitation_id is None + if invitation_id is not None: + if request.poll_id is None: + raise SchedulingError("Scheduling invitation action is unavailable") + participation_provider = _poll_participation_provider() + if participation_provider is None: + raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON) + try: + revocation = participation_provider.revoke_invitation( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + invitation_id=invitation_id, + ) + except PollCapabilityError as exc: + raise SchedulingError( + "Scheduling invitation action could not be completed" + ) from exc + replayed = revocation.replayed + participant.poll_invitation_id = None + participant.participation_gateway = None + if participant.status == "invited": + participant.status = "draft" + session.flush() + return SchedulingInvitationActionResult( + request=request, + participant=participant, + action="revoke", + status="revoked", + replayed=replayed, + ) def _update_participant_invitation_expiry( @@ -2695,7 +2851,6 @@ def _reconcile_scheduling_participants( *, request: SchedulingRequest, supplied_participants: list[SchedulingParticipantReconcileInput], - create_invitations: bool, ) -> dict[str, str]: """Apply a complete participant collection and revoke removed links.""" @@ -2753,12 +2908,6 @@ def _reconcile_scheduling_participants( ) session.add(participant) session.flush() - if create_invitations: - invitation_tokens[participant.id] = _create_reconciled_participant_invitation( - session, - request=request, - participant=participant, - ) participation_provider = _poll_participation_provider() for participant in removed_participants: diff --git a/tests/test_response_editing.py b/tests/test_response_editing.py index 4ba5071..1cdb9e9 100644 --- a/tests/test_response_editing.py +++ b/tests/test_response_editing.py @@ -60,6 +60,7 @@ from govoplan_scheduling.backend.service import ( cancel_scheduling_request, create_scheduling_request, get_public_scheduling_participation, + issue_scheduling_participant_invitation, scheduling_request_summary, scheduling_slot_revision, submit_scheduling_availability, @@ -149,8 +150,9 @@ class SchedulingResponseEditingTests(unittest.TestCase): participants: list[SchedulingParticipantInput] | None = None, **settings, ) -> tuple[SchedulingRequest, dict[str, str]]: + issue_links = bool(settings.pop("create_participant_invitations", True)) start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc) - return create_scheduling_request( + request, automatic_tokens = create_scheduling_request( self.session, tenant_id="tenant-1", user_id="organizer-1", @@ -184,6 +186,31 @@ class SchedulingResponseEditingTests(unittest.TestCase): **settings, ), ) + self.assertEqual(automatic_tokens, {}) + tokens = ( + { + participant.id: self._issue_copy(request, participant) + for participant in request.participants + } + if issue_links + else {} + ) + return request, tokens + + def _issue_copy( + self, + request: SchedulingRequest, + participant: SchedulingParticipant, + ) -> str: + result = issue_scheduling_participant_invitation( + self.session, + tenant_id=request.tenant_id, + request_id=request.id, + participant_id=participant.id, + action="copy", + ) + self.assertIsNotNone(result.action_url) + return str(result.action_url).rsplit("/", 1)[-1] def _answer( self, @@ -475,7 +502,8 @@ class SchedulingResponseEditingTests(unittest.TestCase): ], ), ) - token = tokens[public_request.participants[0].id] + self.assertEqual(tokens, {}) + token = self._issue_copy(public_request, public_request.participants[0]) invitation = self.session.query(PollInvitation).filter( PollInvitation.id == public_request.participants[0].poll_invitation_id ).one() @@ -568,7 +596,8 @@ class SchedulingResponseEditingTests(unittest.TestCase): participants=[SchedulingParticipantInput(display_name="Guest")], ), ) - token = tokens[request.participants[0].id] + self.assertEqual(tokens, {}) + token = self._issue_copy(request, request.participants[0]) wrong = SchedulingPublicParticipationAccessRequest(password="wrong password") for attempt in range(10): @@ -623,7 +652,8 @@ class SchedulingResponseEditingTests(unittest.TestCase): ), ) participant = request.participants[0] - token = tokens[participant.id] + self.assertEqual(tokens, {}) + token = self._issue_copy(request, participant) answer = SchedulingAvailabilityAnswerInput( slot_id=request.slots[0].id, value="available", @@ -1036,8 +1066,16 @@ class SchedulingResponseEditingTests(unittest.TestCase): self.assertIsNotNone(removed_slot.deleted_at) bob = next(item for item in response.participants if item.display_name == "Bob") self.assertEqual(bob.email, "bob@example.test") - self.assertIsNotNone(bob.poll_invitation_id) - self.assertIsNotNone(bob.invitation_token) + self.assertIsNone(bob.poll_invitation_id) + self.assertIsNone(bob.invitation_token) + bob_model = next( + item + for item in request.participants + if item.deleted_at is None and item.display_name == "Bob" + ) + self._issue_copy(request, bob_model) + bob_invitation_id = bob_model.poll_invitation_id + self.assertIsNotNone(bob_invitation_id) current = api_get_my_scheduling_availability( request.id, session=self.session, @@ -1091,7 +1129,7 @@ class SchedulingResponseEditingTests(unittest.TestCase): ), ) invitation = self.session.query(PollInvitation).filter( - PollInvitation.id == bob.poll_invitation_id + PollInvitation.id == bob_invitation_id ).one() self.assertIsNotNone(invitation.revoked_at) diff --git a/tests/test_service.py b/tests/test_service.py index 13960dd..59fd74d 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import patch -from fastapi import HTTPException +from fastapi import HTTPException, Response from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -42,7 +42,9 @@ from govoplan_scheduling.backend.schemas import ( SchedulingCalendarPreferences, SchedulingCandidateSlotInput, SchedulingDecisionRequest, + SchedulingInvitationActionRequest, SchedulingParticipantInput, + SchedulingPublicParticipationAccessRequest, SchedulingPublicParticipationSubmitRequest, SchedulingRequestCreateRequest, SchedulingRequestUpdateRequest, @@ -55,9 +57,11 @@ from govoplan_scheduling.backend.router import ( api_evaluate_calendar_freebusy, api_get_my_scheduling_availability, api_get_scheduling_request, + api_issue_scheduling_participant_invitation, api_list_scheduling_requests, api_scheduling_summary, api_submit_scheduling_availability, + api_revoke_scheduling_participant_invitation, ) from govoplan_scheduling.backend.service import ( SchedulingError, @@ -70,6 +74,8 @@ from govoplan_scheduling.backend.service import ( decide_scheduling_request, evaluate_calendar_freebusy, get_visible_scheduling_request, + get_public_scheduling_participation, + issue_scheduling_participant_invitation, list_scheduling_notifications, list_visible_scheduling_notifications, list_visible_scheduling_requests, @@ -206,7 +212,22 @@ class SchedulingServiceTests(unittest.TestCase): ], ) - def test_create_request_creates_poll_slots_and_signed_invitations(self) -> None: + def _issue_copy( + self, + request: SchedulingRequest, + participant: SchedulingParticipant, + ) -> str: + result = issue_scheduling_participant_invitation( + self.session, + tenant_id=request.tenant_id, + request_id=request.id, + participant_id=participant.id, + action="copy", + ) + self.assertIsNotNone(result.action_url) + return str(result.action_url).rsplit("/", 1)[-1] + + def test_create_request_creates_poll_slots_without_implicit_invitations(self) -> None: request, tokens = create_scheduling_request( self.session, tenant_id="tenant-1", @@ -224,8 +245,20 @@ class SchedulingServiceTests(unittest.TestCase): self.assertEqual(poll.context_resource_id, request.id) self.assertEqual(len(request.slots), 2) self.assertTrue(all(slot.poll_option_id for slot in request.slots)) - self.assertEqual(len(tokens), 2) - self.assertTrue(all(participant.poll_invitation_id for participant in request.participants)) + self.assertEqual(tokens, {}) + self.assertTrue( + all( + participant.status == "draft" + and participant.poll_invitation_id is None + for participant in request.participants + ) + ) + self.assertEqual( + self.session.query(SchedulingNotification).filter( + SchedulingNotification.request_id == request.id + ).count(), + 0, + ) def test_draft_save_does_not_issue_or_deliver_public_invitations(self) -> None: class RejectingNotificationProvider: @@ -331,11 +364,13 @@ class SchedulingServiceTests(unittest.TestCase): first_participant = request.participants[0] first_slot = request.slots[0] second_slot = request.slots[1] + self.assertEqual(tokens, {}) + token = self._issue_copy(request, first_participant) submit_public_scheduling_participation( self.session, request_id=request.id, - token=tokens[first_participant.id], + token=token, payload=SchedulingPublicParticipationSubmitRequest( answers=[ SchedulingAvailabilityAnswerInput( @@ -584,7 +619,7 @@ class SchedulingServiceTests(unittest.TestCase): all_jobs = list_scheduling_notifications(self.session, tenant_id="tenant-1", request_id=request.id) self.assertEqual(len(reminder_jobs), 2) - self.assertGreaterEqual(len(all_jobs), 4) + self.assertEqual(len(all_jobs), 2) self.assertTrue(all(job.status == "pending" for job in reminder_jobs)) organizer_jobs = list_visible_scheduling_notifications( @@ -1057,6 +1092,7 @@ class SchedulingServiceTests(unittest.TestCase): payload=payload, ) target = request.participants[1] + self._issue_copy(request, target) attacker = self._principal( "attacker", email="alice@example.test", @@ -1095,7 +1131,7 @@ class SchedulingServiceTests(unittest.TestCase): principal=attacker, ) - self.assertEqual(direct_response.exception.status_code, 400) + self.assertEqual(direct_response.exception.status_code, 404) self.assertEqual([request.id], [item.id for item in listed.requests]) self.assertFalse(current.has_response) @@ -1225,10 +1261,12 @@ class SchedulingServiceTests(unittest.TestCase): payload=payload, ) participant = request.participants[0] + self.assertEqual(tokens, {}) + token = self._issue_copy(request, participant) submit_public_scheduling_participation( self.session, request_id=request.id, - token=tokens[participant.id], + token=token, payload=SchedulingPublicParticipationSubmitRequest( answers=[ SchedulingAvailabilityAnswerInput( @@ -1436,7 +1474,7 @@ class SchedulingServiceTests(unittest.TestCase): self.assertIsNone(request.selected_slot_id) self.assertIsNone(request.calendar_event_id) - def test_initial_invitation_notifications_use_signed_poll_link_and_verified_recipient_id(self) -> None: + def test_explicit_invitation_send_uses_signed_link_and_verified_recipient_id(self) -> None: class CapturingNotificationProvider: def __init__(self) -> None: self.requests = [] @@ -1466,22 +1504,41 @@ class SchedulingServiceTests(unittest.TestCase): "govoplan_scheduling.backend.service.notification_dispatch_provider", return_value=provider, ): - request, tokens = create_scheduling_request( + request, automatic_tokens = create_scheduling_request( self.session, tenant_id="tenant-1", user_id="user-1", payload=payload, ) + self.assertEqual(provider.requests, []) + results = [ + issue_scheduling_participant_invitation( + self.session, + tenant_id=request.tenant_id, + request_id=request.id, + participant_id=participant.id, + action="send", + ) + for participant in request.participants + ] + self.assertEqual(automatic_tokens, {}) + self.assertTrue(all(result.action_url is None for result in results)) + self.assertTrue(all(result.status == "queued" for result in results)) self.assertEqual(len(provider.requests), 2) self.assertEqual({item.recipient_id for item in provider.requests}, {"alice-id", "bob-id"}) - self.assertEqual( - {item.action_url for item in provider.requests}, - { - f"/scheduling/public/{request.id}/{token}" - for token in tokens.values() - }, + action_urls = {item.action_url for item in provider.requests} + self.assertTrue( + all( + isinstance(action_url, str) + and action_url.startswith(f"/scheduling/public/{request.id}/") + for action_url in action_urls + ) ) + tokens = { + str(action_url).rsplit("/", 1)[-1] + for action_url in action_urls + } local_notifications = list_scheduling_notifications( self.session, tenant_id="tenant-1", @@ -1489,7 +1546,228 @@ class SchedulingServiceTests(unittest.TestCase): ) for notification in local_notifications: serialized = repr({"payload": notification.payload, "metadata": notification.metadata_}) - self.assertTrue(all(token not in serialized for token in tokens.values())) + self.assertTrue(all(token not in serialized for token in tokens)) + + def test_invitation_router_rotates_revokes_and_enforces_management_policy(self) -> None: + request, automatic_tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy( + update={"calendar": SchedulingCalendarPreferences()} + ), + ) + participant = request.participants[0] + organizer = self._principal( + "user-1", + scopes={SCHEDULING_WRITE_SCOPE}, + ) + response_headers = Response() + with patch("govoplan_scheduling.backend.router.audit_event") as audit: + first = api_issue_scheduling_participant_invitation( + request.id, + participant.id, + SchedulingInvitationActionRequest(action="copy"), + response_headers, + session=self.session, + principal=organizer, + ) + + self.assertEqual(automatic_tokens, {}) + self.assertEqual(first.action, "copy") + self.assertEqual(first.status, "issued") + self.assertIsNotNone(first.action_url) + self.assertEqual( + response_headers.headers["cache-control"], + "no-store, private", + ) + first_token = str(first.action_url).rsplit("/", 1)[-1] + self.assertNotIn(first_token, repr([call.kwargs for call in audit.call_args_list])) + + unrelated_writer = self._principal( + "unrelated", + scopes={SCHEDULING_WRITE_SCOPE}, + ) + with self.assertRaises(HTTPException) as denied: + api_issue_scheduling_participant_invitation( + request.id, + participant.id, + SchedulingInvitationActionRequest(action="copy"), + Response(), + session=self.session, + principal=unrelated_writer, + ) + self.assertEqual(denied.exception.status_code, 403) + self.assertEqual( + get_public_scheduling_participation( + self.session, + request_id=request.id, + token=first_token, + payload=SchedulingPublicParticipationAccessRequest(), + client_address="127.0.0.1", + )["request_id"], + request.id, + ) + + administrator = self._principal( + "administrator", + scopes={SCHEDULING_ADMIN_SCOPE}, + ) + with patch("govoplan_scheduling.backend.router.audit_event") as audit: + rotated = api_issue_scheduling_participant_invitation( + request.id, + participant.id, + SchedulingInvitationActionRequest(action="copy"), + Response(), + session=self.session, + principal=administrator, + ) + rotated_token = str(rotated.action_url).rsplit("/", 1)[-1] + self.assertNotEqual(rotated_token, first_token) + self.assertNotIn(rotated_token, repr([call.kwargs for call in audit.call_args_list])) + with self.assertRaisesRegex( + Exception, + "Scheduling participation link or credentials are invalid", + ): + get_public_scheduling_participation( + self.session, + request_id=request.id, + token=first_token, + payload=SchedulingPublicParticipationAccessRequest(), + client_address="127.0.0.1", + ) + + with patch("govoplan_scheduling.backend.router.audit_event") as audit: + revoked = api_revoke_scheduling_participant_invitation( + request.id, + participant.id, + Response(), + session=self.session, + principal=organizer, + ) + replayed = api_revoke_scheduling_participant_invitation( + request.id, + participant.id, + Response(), + session=self.session, + principal=organizer, + ) + self.assertFalse(revoked.replayed) + self.assertTrue(replayed.replayed) + self.assertNotIn(rotated_token, repr([call.kwargs for call in audit.call_args_list])) + with self.assertRaisesRegex( + Exception, + "Scheduling participation link or credentials are invalid", + ): + get_public_scheduling_participation( + self.session, + request_id=request.id, + token=rotated_token, + payload=SchedulingPublicParticipationAccessRequest(), + client_address="127.0.0.1", + ) + + def test_links_can_be_issued_for_draft_and_closed_requests(self) -> None: + organizer = self._principal( + "user-1", + scopes={SCHEDULING_WRITE_SCOPE}, + ) + for request_status in ("draft", "collecting"): + request, _automatic_tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy( + update={ + "status": request_status, + "calendar": SchedulingCalendarPreferences(), + } + ), + ) + if request_status == "collecting": + close_scheduling_request( + self.session, + tenant_id=request.tenant_id, + request_id=request.id, + ) + expected_status = "closed" + else: + expected_status = "draft" + with patch("govoplan_scheduling.backend.router.audit_event"): + issued = api_issue_scheduling_participant_invitation( + request.id, + request.participants[0].id, + SchedulingInvitationActionRequest(action="copy"), + Response(), + session=self.session, + principal=organizer, + ) + self.assertEqual(request.status, expected_status) + self.assertTrue( + str(issued.action_url).startswith( + f"/scheduling/public/{request.id}/" + ) + ) + + def test_send_response_audit_error_and_durable_job_never_store_token(self) -> None: + class EchoingFailureNotificationProvider: + def __init__(self) -> None: + self.requests = [] + + def enqueue_notification(self, _session, request, *, enqueue_delivery): + self.requests.append(request) + raise RuntimeError(f"failed dispatch for {request.action_url}") + + provider = EchoingFailureNotificationProvider() + request, _automatic_tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy( + update={"calendar": SchedulingCalendarPreferences()} + ), + ) + participant = request.participants[0] + organizer = self._principal( + "user-1", + scopes={SCHEDULING_WRITE_SCOPE}, + ) + with ( + patch( + "govoplan_scheduling.backend.service.notification_dispatch_provider", + return_value=provider, + ), + patch("govoplan_scheduling.backend.router.audit_event") as audit, + ): + result = api_issue_scheduling_participant_invitation( + request.id, + participant.id, + SchedulingInvitationActionRequest(action="send"), + Response(), + session=self.session, + principal=organizer, + ) + + self.assertEqual(len(provider.requests), 1) + action_url = provider.requests[0].action_url + self.assertIsInstance(action_url, str) + token = str(action_url).rsplit("/", 1)[-1] + self.assertIsNone(result.action_url) + self.assertEqual(result.status, "failed") + self.assertEqual(result.notification.error, "Notification center enqueue failed") + self.assertNotIn(token, repr(result.model_dump())) + self.assertNotIn(token, repr([call.kwargs for call in audit.call_args_list])) + notification = self.session.query(SchedulingNotification).filter( + SchedulingNotification.id == result.notification.id + ).one() + durable_projection = repr( + { + "payload": notification.payload, + "metadata": notification.metadata_, + "error": notification.error, + } + ) + self.assertNotIn(token, durable_projection) def test_external_participants_can_be_rejected(self) -> None: payload = self._payload().model_copy(update={"allow_external_participants": False})