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