feat(scheduling): enforce governed response policies
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user