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

@@ -17,7 +17,10 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("access", manifest.optional_dependencies)
self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("policy", manifest.optional_dependencies)
self.assertEqual(("poll.scheduling",), manifest.required_capabilities)
self.assertEqual(
("poll.scheduling", "poll.participation_gateway"),
manifest.required_capabilities,
)
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIn("poll.scheduling", manifest.required_capabilities)
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
@@ -29,14 +32,18 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.response_collection", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.governed_participation", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces}
self.assertEqual("0.1.9", required_interfaces["poll.availability_matrix"].version_min)
self.assertEqual("0.1.9", required_interfaces["poll.response_collection"].version_min)
self.assertEqual("0.1.9", required_interfaces["poll.workflow_context"].version_min)
for interface_name in (
"poll.availability_matrix",
"poll.response_collection",
"poll.workflow_context",
"poll.governed_participation",
):
self.assertEqual("0.1.10", required_interfaces[interface_name].version_min)
if __name__ == "__main__":

View File

@@ -20,7 +20,7 @@ from govoplan_scheduling.backend.db.models import (
from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
_SCHEDULING_HEAD = "9c2f4a7d1e6b"
_SCHEDULING_HEAD = "ad7e3c9b2f10"
_ENABLED_MODULES = ("poll", "scheduling")
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
@@ -110,12 +110,27 @@ class SchedulingMigrationTests(unittest.TestCase):
"scheduling_requests"
)
}
participant_columns = {
item["name"]
for item in inspect(connection).get_columns(
"scheduling_participants"
)
}
visibility = connection.execute(
text(
"SELECT participant_visibility FROM scheduling_requests "
"WHERE id = 'request-1'"
)
).scalar_one()
response_defaults = connection.execute(
text(
"SELECT notify_on_answers, single_choice, "
"max_participants_per_option, allow_maybe, allow_comments, "
"participant_email_required, anonymous_password_protection_enabled, "
"anonymous_password_hash FROM scheduling_requests "
"WHERE id = 'request-1'"
)
).one()
counts = {
model.__tablename__: connection.execute(
select(func.count()).select_from(model)
@@ -130,7 +145,11 @@ class SchedulingMigrationTests(unittest.TestCase):
self.assertIn(_SCHEDULING_HEAD, heads)
self.assertIn("participant_visibility", columns)
self.assertIn("max_participants_per_option", columns)
self.assertIn("response_comment", participant_columns)
self.assertIn("participation_gateway", participant_columns)
self.assertEqual(visibility, "aggregates_only")
self.assertEqual(tuple(response_defaults), (1, 0, None, 1, 0, 0, 0, None))
self.assertEqual(set(counts.values()), {1})
finally:
engine.dispose()
@@ -219,6 +238,74 @@ class SchedulingMigrationTests(unittest.TestCase):
finally:
engine.dispose()
def test_rejects_partial_existing_response_settings(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-scheduling-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
self._migrate(url)
engine = create_engine(url)
try:
with engine.begin() as connection:
self._remove_scheduling_revision(
connection,
remove_privacy_column=False,
)
connection.execute(
text(
"ALTER TABLE scheduling_requests "
"DROP COLUMN allow_comments"
)
)
with self.assertRaisesRegex(
RuntimeError,
"partial scheduling_requests scheduling response settings",
):
self._migrate(url)
with engine.connect() as connection:
heads = set(
MigrationContext.configure(connection).get_current_heads()
)
self.assertNotIn(_SCHEDULING_HEAD, heads)
finally:
engine.dispose()
def test_rejects_partial_existing_participant_response_settings(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-scheduling-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
self._migrate(url)
engine = create_engine(url)
try:
with engine.begin() as connection:
self._remove_scheduling_revision(
connection,
remove_privacy_column=False,
)
connection.execute(
text(
"ALTER TABLE scheduling_participants "
"DROP COLUMN participation_gateway"
)
)
with self.assertRaisesRegex(
RuntimeError,
"partial scheduling_participants scheduling response settings",
):
self._migrate(url)
with engine.connect() as connection:
heads = set(
MigrationContext.configure(connection).get_current_heads()
)
self.assertNotIn(_SCHEDULING_HEAD, heads)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -89,11 +90,46 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
"tenant_id": "tenant-1",
"title": "Privacy review",
"status": "collecting",
"poll_id": "poll-internal",
"organizer_user_id": "organizer-1",
"calendar_integration_enabled": True,
"calendar_id": "calendar-internal",
"calendar_freebusy_enabled": True,
"calendar_hold_enabled": True,
"create_calendar_event_on_decision": True,
"calendar_event_id": "event-internal",
"metadata_": {"connector_secret_ref": "secret-internal"},
}
if participant_visibility is not None:
values["participant_visibility"] = participant_visibility
request = SchedulingRequest(**values)
request.slots = [
SchedulingCandidateSlot(
tenant_id="tenant-1",
poll_option_id="poll-option-internal",
label="Monday morning",
start_at=datetime(2026, 7, 27, 9, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 27, 10, tzinfo=timezone.utc),
timezone="Europe/Berlin",
position=0,
freebusy_checked_at=datetime(2026, 7, 21, 12, tzinfo=timezone.utc),
freebusy_status="busy",
freebusy_conflicts=[
{
"calendar_id": "calendar-internal",
"event_id": "event-internal",
"uid": "connector-uid-internal",
"recurrence_id": "recurrence-internal",
"start_at": "2026-07-27T09:30:00+00:00",
"end_at": "2026-07-27T09:45:00+00:00",
"status": "CONFIRMED",
},
{"error": "connector-internal failure"},
],
tentative_hold_event_id="hold-internal",
metadata_={"provider_ref": "provider-internal"},
)
]
request.participants = [
SchedulingParticipant(
tenant_id="tenant-1",
@@ -102,6 +138,9 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
email="alice@example.test",
participant_type="internal",
status="responded",
poll_invitation_id="invitation-alice-internal",
last_invited_at=datetime(2026, 7, 20, 12, tzinfo=timezone.utc),
responded_at=datetime(2026, 7, 21, 12, tzinfo=timezone.utc),
metadata_={"private": "alice"},
),
SchedulingParticipant(
@@ -152,10 +191,43 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
self.assertEqual(response.participant_visibility, "aggregates_only")
self.assertEqual(response.effective_participant_visibility, "aggregates_only")
self.assertEqual([participant.display_name for participant in response.participants], ["Alice"])
self.assertEqual(response.participants[0].email, "alice@example.test")
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.participant_type)
self.assertIsNone(own.required)
self.assertIsNone(own.poll_invitation_id)
self.assertEqual(own.metadata, {})
self.assertEqual(response.participant_aggregate.total, 2)
self.assertEqual(response.participant_aggregate.status_counts["responded"], 1)
self.assertEqual(response.participant_aggregate.status_counts["invited"], 1)
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, {})
slot = response.slots[0]
self.assertIsNone(slot.poll_option_id)
self.assertEqual(slot.freebusy_status, "busy")
self.assertEqual(
slot.freebusy_conflicts,
[
{
"start_at": "2026-07-27T09:30:00+00:00",
"end_at": "2026-07-27T09:45:00+00:00",
"status": "CONFIRMED",
}
],
)
self.assertIsNone(slot.tentative_hold_event_id)
self.assertEqual(slot.metadata, {})
def test_configured_roster_returns_other_names_and_statuses_with_sensitive_fields_redacted(self) -> None:
request = self._request(participant_visibility="names_and_statuses")
@@ -165,11 +237,15 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
self.assertEqual(response.effective_participant_visibility, "names_and_statuses")
own = next(participant for participant in response.participants if participant.display_name == "Alice")
other = next(participant for participant in response.participants if participant.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(other.status, "invited")
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.assertEqual(other.metadata, {})
@@ -199,6 +275,18 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
"bob@example.test",
})
self.assertTrue(response.participant_visibility_decision.details["management_access"])
self.assertEqual(response.tenant_id, "tenant-1")
self.assertEqual(response.poll_id, "poll-internal")
self.assertEqual(response.organizer_user_id, "organizer-1")
self.assertEqual(response.calendar_id, "calendar-internal")
self.assertEqual(response.calendar_event_id, "event-internal")
self.assertEqual(response.metadata["connector_secret_ref"], "secret-internal")
self.assertEqual(response.slots[0].poll_option_id, "poll-option-internal")
self.assertEqual(
response.slots[0].freebusy_conflicts[0]["uid"],
"connector-uid-internal",
)
self.assertEqual(response.slots[0].tentative_hold_event_id, "hold-internal")
def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None:
restricting_policy = _PrivacyPolicy("aggregates_only")
@@ -211,8 +299,8 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
self.assertEqual([participant.display_name for participant in reduced.participants], ["Alice"])
self.assertTrue(reduced.participant_visibility_decision.policy_applied)
self.assertEqual(reduced.participant_visibility_decision.reason, "Tenant participant privacy policy")
self.assertEqual(reduced.participant_visibility_decision.source_path[0]["path"], "tenant:tenant-1")
self.assertTrue(reduced.participant_visibility_decision.details["provider_session_available"])
self.assertEqual(reduced.participant_visibility_decision.source_path, [])
self.assertEqual(reduced.participant_visibility_decision.details, {})
self.assertEqual(restricting_policy.requests[0].actor_user_id, "alice-account")
widening_policy = _PrivacyPolicy("names_and_statuses")

File diff suppressed because it is too large Load Diff

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,