feat(scheduling): enforce governed response policies

This commit is contained in:
2026-07-21 21:17:26 +02:00
parent 886579942f
commit b1725b8f59
12 changed files with 3546 additions and 181 deletions

View File

@@ -19,13 +19,21 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_access.backend.db.models import Account, User
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollOption,
PollParticipationSubmission,
PollResponse,
)
from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest
from govoplan_poll.backend.router import api_get_poll, api_list_polls, api_submit_poll_response
from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest
from govoplan_poll.backend.service import get_poll, submit_poll_response_with_token
from govoplan_poll.backend.service import get_poll
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.manifest import READ_SCOPE as SCHEDULING_READ_SCOPE
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE as SCHEDULING_ADMIN_SCOPE
from govoplan_scheduling.backend.manifest import RESPOND_SCOPE as SCHEDULING_RESPOND_SCOPE
from govoplan_scheduling.backend.manifest import WRITE_SCOPE as SCHEDULING_WRITE_SCOPE
from govoplan_scheduling.backend.schemas import (
@@ -35,6 +43,7 @@ from govoplan_scheduling.backend.schemas import (
SchedulingCandidateSlotInput,
SchedulingDecisionRequest,
SchedulingParticipantInput,
SchedulingPublicParticipationSubmitRequest,
SchedulingRequestCreateRequest,
SchedulingRequestUpdateRequest,
)
@@ -68,6 +77,7 @@ from govoplan_scheduling.backend.service import (
require_visible_scheduling_results,
scheduling_request_summary,
scheduling_slot_revision,
submit_public_scheduling_participation,
update_scheduling_request,
)
from govoplan_scheduling.backend.runtime import configure_runtime
@@ -88,6 +98,7 @@ class SchedulingServiceTests(unittest.TestCase):
PollOption.__table__,
PollResponse.__table__,
PollInvitation.__table__,
PollParticipationSubmission.__table__,
PollLifecycleTransition.__table__,
CalendarCollection.__table__,
CalendarEvent.__table__,
@@ -121,6 +132,7 @@ class SchedulingServiceTests(unittest.TestCase):
CalendarSyncSource.__table__,
CalendarEvent.__table__,
CalendarCollection.__table__,
PollParticipationSubmission.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
PollResponse.__table__,
@@ -215,6 +227,82 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual(len(tokens), 2)
self.assertTrue(all(participant.poll_invitation_id for participant in request.participants))
def test_draft_save_does_not_issue_or_deliver_public_invitations(self) -> None:
class RejectingNotificationProvider:
def enqueue_notification(self, *_args, **_kwargs):
raise AssertionError("A draft must not enqueue invitation delivery")
with patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=RejectingNotificationProvider(),
):
request, tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(update={"status": "draft"}),
)
self.assertEqual(tokens, {})
self.assertEqual(
self.session.query(PollInvitation).filter(
PollInvitation.poll_id == request.poll_id
).count(),
0,
)
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,
)
open_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
)
api_submit_scheduling_availability(
request.id,
SchedulingAvailabilityResponseRequest(
answers=[
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
)
]
),
session=self.session,
principal=self._principal(
"alice-account",
email="alice@example.test",
scopes={SCHEDULING_RESPOND_SCOPE},
),
)
alice = next(
participant
for participant in request.participants
if participant.email == "alice@example.test"
)
self.assertEqual(alice.status, "responded")
self.assertEqual(alice.participation_gateway, "scheduling")
self.assertIsNotNone(alice.poll_invitation_id)
self.assertEqual(
self.session.query(SchedulingNotification).filter(
SchedulingNotification.request_id == request.id,
SchedulingNotification.event_kind == "invitation",
).count(),
0,
)
def test_create_route_persists_after_request_session_closes(self) -> None:
response = api_create_scheduling_request(
self._payload().model_copy(update={"calendar": SchedulingCalendarPreferences()}),
@@ -244,15 +332,25 @@ class SchedulingServiceTests(unittest.TestCase):
first_slot = request.slots[0]
second_slot = request.slots[1]
submit_poll_response_with_token(
submit_public_scheduling_participation(
self.session,
request_id=request.id,
token=tokens[first_participant.id],
payload=PollSubmitResponseRequest(
payload=SchedulingPublicParticipationSubmitRequest(
answers=[
PollAnswerInput(option_id=first_slot.poll_option_id, value="available"),
PollAnswerInput(option_id=second_slot.poll_option_id, value="maybe"),
SchedulingAvailabilityAnswerInput(
slot_id=first_slot.id,
value="available",
option_revision=scheduling_slot_revision(first_slot),
),
SchedulingAvailabilityAnswerInput(
slot_id=second_slot.id,
value="maybe",
option_revision=scheduling_slot_revision(second_slot),
),
]
),
client_address="127.0.0.1",
)
summary = scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id)
@@ -550,6 +648,142 @@ class SchedulingServiceTests(unittest.TestCase):
).id,
)
ordinary_writer = self._principal(
"unrelated-writer",
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_WRITE_SCOPE},
)
administrator = self._principal(
"scheduling-admin",
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_ADMIN_SCOPE},
)
self.assertEqual(
[],
api_list_scheduling_requests(
status_filter=None,
session=self.session,
principal=ordinary_writer,
).requests,
)
self.assertEqual(
[request.id],
[
item.id
for item in api_list_scheduling_requests(
status_filter=None,
session=self.session,
principal=administrator,
).requests
],
)
def test_participant_list_and_get_redact_management_and_connector_internals(self) -> None:
request, _tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload(),
)
request.calendar_event_id = "calendar-event-internal"
request.metadata_ = {"connector_ref": "connector-internal"}
slot = request.slots[0]
slot.freebusy_status = "busy"
slot.freebusy_conflicts = [
{
"calendar_id": "calendar-1",
"event_id": "busy-event-internal",
"uid": "busy-uid-internal",
"recurrence_id": "busy-recurrence-internal",
"start_at": "2026-07-20T09:15:00+00:00",
"end_at": "2026-07-20T09:45:00+00:00",
"status": "CONFIRMED",
}
]
slot.tentative_hold_event_id = "hold-event-internal"
slot.metadata_ = {"provider_ref": "provider-internal"}
participant = request.participants[0]
participant.respondent_id = "alice-identity-internal"
participant.metadata_ = {"directory_ref": "directory-internal"}
self.session.flush()
principal = self._principal(
"alice-account",
email="alice@example.test",
scopes={SCHEDULING_READ_SCOPE},
)
listed = api_list_scheduling_requests(
status_filter=None,
session=self.session,
principal=principal,
).requests
fetched = api_get_scheduling_request(
request.id,
session=self.session,
principal=principal,
)
self.assertEqual(len(listed), 1)
for response in (listed[0], fetched):
self.assertIsNone(response.tenant_id)
self.assertIsNone(response.poll_id)
self.assertIsNone(response.organizer_user_id)
self.assertIsNone(response.calendar_integration_enabled)
self.assertIsNone(response.calendar_id)
self.assertIsNone(response.calendar_freebusy_enabled)
self.assertIsNone(response.calendar_hold_enabled)
self.assertIsNone(response.create_calendar_event_on_decision)
self.assertIsNone(response.calendar_event_id)
self.assertIsNone(
response.public_participation_policy_enforcement_available
)
self.assertEqual(response.metadata, {})
self.assertEqual(response.participant_aggregate.total, 2)
self.assertEqual(len(response.participants), 1)
own = response.participants[0]
self.assertTrue(own.is_current_participant)
self.assertEqual(own.email, "alice@example.test")
self.assertIsNone(own.respondent_id)
self.assertIsNone(own.poll_invitation_id)
self.assertEqual(own.metadata, {})
projected_slot = response.slots[0]
self.assertIsNone(projected_slot.poll_option_id)
self.assertEqual(projected_slot.freebusy_status, "busy")
self.assertEqual(
projected_slot.freebusy_conflicts,
[
{
"start_at": "2026-07-20T09:15:00+00:00",
"end_at": "2026-07-20T09:45:00+00:00",
"status": "CONFIRMED",
}
],
)
self.assertIsNone(projected_slot.tentative_hold_event_id)
self.assertEqual(projected_slot.metadata, {})
organizer = self._principal(
"user-1",
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_WRITE_SCOPE},
)
management = api_get_scheduling_request(
request.id,
session=self.session,
principal=organizer,
)
self.assertEqual(management.tenant_id, "tenant-1")
self.assertEqual(management.poll_id, request.poll_id)
self.assertEqual(management.calendar_id, "calendar-1")
self.assertEqual(management.calendar_event_id, "calendar-event-internal")
self.assertEqual(management.metadata["connector_ref"], "connector-internal")
self.assertEqual(management.slots[0].poll_option_id, slot.poll_option_id)
self.assertEqual(
management.slots[0].freebusy_conflicts[0]["uid"],
"busy-uid-internal",
)
self.assertEqual(
management.slots[0].tentative_hold_event_id,
"hold-event-internal",
)
def test_result_visibility_follows_existing_after_close_policy(self) -> None:
request, _tokens = create_scheduling_request(
self.session,
@@ -800,7 +1034,7 @@ class SchedulingServiceTests(unittest.TestCase):
organizer = self._principal("user-1", scopes={"poll:poll:read"})
self.assertEqual(api_get_poll(request.poll_id, session=self.session, principal=organizer).id, request.poll_id)
def test_authenticated_response_reconciles_only_the_current_participant(self) -> None:
def test_module_owned_poll_rejects_direct_response_and_uses_current_participant(self) -> None:
participants = [
SchedulingParticipantInput(
respondent_id="alice-membership",
@@ -835,15 +1069,21 @@ class SchedulingServiceTests(unittest.TestCase):
},
)
response = api_submit_poll_response(
request.poll_id,
PollSubmitResponseRequest(
answers=[PollAnswerInput(option_id=request.slots[0].poll_option_id, value="available")],
metadata={"invitation_id": target.poll_invitation_id},
),
session=self.session,
principal=attacker,
)
with self.assertRaises(HTTPException) as direct_response:
api_submit_poll_response(
request.poll_id,
PollSubmitResponseRequest(
answers=[
PollAnswerInput(
option_id=request.slots[0].poll_option_id,
value="available",
)
],
metadata={"invitation_id": target.poll_invitation_id},
),
session=self.session,
principal=attacker,
)
listed = api_list_scheduling_requests(
status_filter=None,
session=self.session,
@@ -855,8 +1095,29 @@ class SchedulingServiceTests(unittest.TestCase):
principal=attacker,
)
self.assertNotIn("invitation_id", response.metadata)
self.assertEqual(direct_response.exception.status_code, 400)
self.assertEqual([request.id], [item.id for item in listed.requests])
self.assertFalse(current.has_response)
api_submit_scheduling_availability(
request.id,
SchedulingAvailabilityResponseRequest(
answers=[
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
)
]
),
session=self.session,
principal=attacker,
)
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=attacker,
)
self.assertTrue(current.has_response)
self.assertEqual(
[(answer.slot_id, answer.value) for answer in current.answers],
@@ -964,17 +1225,20 @@ class SchedulingServiceTests(unittest.TestCase):
payload=payload,
)
participant = request.participants[0]
submit_poll_response_with_token(
submit_public_scheduling_participation(
self.session,
request_id=request.id,
token=tokens[participant.id],
payload=PollSubmitResponseRequest(
payload=SchedulingPublicParticipationSubmitRequest(
answers=[
PollAnswerInput(
option_id=request.slots[0].poll_option_id,
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
)
]
),
client_address="127.0.0.1",
)
alice = self._principal(
"alice-account",
@@ -1010,7 +1274,7 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual(len(responses), 1)
self.assertEqual(
responses[0].respondent_id,
f"invitation:{participant.poll_invitation_id}",
participant.respondent_id,
)
self.assertEqual(
responses[0].metadata_["invitation_id"],
@@ -1071,13 +1335,17 @@ class SchedulingServiceTests(unittest.TestCase):
for response in responses:
own = next(item for item in response.participants if item.display_name == "Alice")
other = next(item for item in response.participants if item.display_name == "Bob")
self.assertEqual(own.respondent_id, "alice-id")
self.assertTrue(own.is_current_participant)
self.assertIsNone(own.respondent_id)
self.assertEqual(own.email, "alice@example.test")
self.assertEqual(own.metadata, {"private": "alice"})
self.assertIsNotNone(own.poll_invitation_id)
self.assertEqual(own.metadata, {})
self.assertIsNone(own.poll_invitation_id)
self.assertIsNone(own.invitation_token)
self.assertFalse(other.is_current_participant)
self.assertIsNone(other.respondent_id)
self.assertIsNone(other.email)
self.assertIsNone(other.participant_type)
self.assertIsNone(other.required)
self.assertIsNone(other.poll_invitation_id)
self.assertIsNone(other.last_invited_at)
self.assertIsNone(other.responded_at)
@@ -1114,13 +1382,13 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertIsNone(request.selected_slot_id)
availability_reader = self._principal(
"reader",
"user-1",
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_AVAILABILITY_READ_SCOPE},
)
freebusy = api_evaluate_calendar_freebusy(request.id, session=self.session, principal=availability_reader)
self.assertTrue(freebusy.updated_slot_ids)
event_writer = self._principal(
"event-writer",
"user-1",
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_EVENT_WRITE_SCOPE},
)
holds = api_create_tentative_calendar_holds(request.id, session=self.session, principal=event_writer)
@@ -1139,7 +1407,7 @@ class SchedulingServiceTests(unittest.TestCase):
self.session.flush()
close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id)
scheduling_writer = self._principal(
"writer",
"user-1",
scopes={SCHEDULING_WRITE_SCOPE},
)
@@ -1209,7 +1477,10 @@ class SchedulingServiceTests(unittest.TestCase):
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"/poll/public/{token}" for token in tokens.values()},
{
f"/scheduling/public/{request.id}/{token}"
for token in tokens.values()
},
)
local_notifications = list_scheduling_notifications(
self.session,