feat(scheduling): bound public cancellation notices

This commit is contained in:
2026-07-22 03:17:51 +02:00
parent ea2f721377
commit 58619484b6
7 changed files with 217 additions and 30 deletions

View File

@@ -19,6 +19,7 @@ from govoplan_core.db.base import Base
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollOption,
PollParticipationSubmission,
PollResponse,
@@ -84,6 +85,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
PollResponse.__table__,
PollInvitation.__table__,
PollParticipationSubmission.__table__,
PollLifecycleTransition.__table__,
SchedulingRequest.__table__,
SchedulingCandidateSlot.__table__,
SchedulingParticipant.__table__,
@@ -104,6 +106,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
SchedulingRequest.__table__,
PollParticipationSubmission.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
PollResponse.__table__,
PollOption.__table__,
Poll.__table__,
@@ -600,6 +603,17 @@ class SchedulingResponseEditingTests(unittest.TestCase):
token = self._issue_copy(request, request.participants[0])
wrong = SchedulingPublicParticipationAccessRequest(password="wrong password")
for _attempt in range(20):
with self.assertRaises(SchedulingPublicParticipationError) as missing:
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="192.0.2.20",
)
self.assertEqual(missing.exception.retry_after_seconds, 0)
for attempt in range(10):
with self.assertRaises(SchedulingPublicParticipationError) as raised:
get_public_scheduling_participation(
@@ -1322,6 +1336,92 @@ class SchedulingResponseEditingTests(unittest.TestCase):
self.assertIsNotNone(cancelled.cancelled_at)
self.assertEqual(poll.status, "draft")
def test_cancellation_link_becomes_bounded_notice_without_request_details(self) -> None:
request, tokens = self._request_and_tokens()
participant = request.participants[0]
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == participant.poll_invitation_id
).one()
token = tokens[participant.id]
cancelled_at = datetime(2026, 7, 22, 12, tzinfo=timezone.utc)
with (
patch.object(scheduling_service, "_now", return_value=cancelled_at),
patch.object(
scheduling_service,
"get_settings",
return_value=SimpleNamespace(
scheduling_cancellation_notice_days=7
),
),
):
cancelled = cancel_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
)
notice_until = cancelled_at + timedelta(days=7)
self.assertEqual(
scheduling_service.response_datetime(
cancelled.cancellation_notice_until
),
notice_until,
)
self.assertEqual(
scheduling_service.response_datetime(invitation.expires_at),
notice_until,
)
with patch.object(
scheduling_service,
"_now",
return_value=cancelled_at + timedelta(days=1),
):
notice = get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="192.0.2.30",
)
self.assertTrue(notice["cancellation_notice_only"])
self.assertEqual(notice["status"], "cancelled")
for private_field in (
"description",
"location",
"deadline_at",
"comment",
"slots",
"answers",
):
self.assertNotIn(private_field, notice)
with patch.object(
scheduling_service,
"_now",
return_value=notice_until + timedelta(seconds=1),
):
with self.assertRaises(SchedulingPublicParticipationError):
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="192.0.2.30",
)
with self.assertRaisesRegex(
SchedulingError,
"cancellation notice has expired",
):
issue_scheduling_participant_invitation(
self.session,
tenant_id=request.tenant_id,
request_id=request.id,
participant_id=participant.id,
action="copy",
)
def test_fully_invalidated_response_becomes_unanswered(self) -> None:
request = self._request()
alice = self._principal(