fix(responses): reject stale scheduling answers
This commit is contained in:
@@ -4,6 +4,8 @@ import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
@@ -40,6 +42,8 @@ from govoplan_scheduling.backend.service import (
|
||||
SchedulingError,
|
||||
cancel_scheduling_request,
|
||||
create_scheduling_request,
|
||||
scheduling_request_summary,
|
||||
scheduling_slot_revision,
|
||||
update_scheduling_candidate_slot,
|
||||
)
|
||||
|
||||
@@ -99,7 +103,12 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
user=SimpleNamespace(id=f"membership-{account_id}"),
|
||||
)
|
||||
|
||||
def _request(self, *, status: str = "collecting") -> SchedulingRequest:
|
||||
def _request(
|
||||
self,
|
||||
*,
|
||||
status: str = "collecting",
|
||||
allow_participant_updates: bool = True,
|
||||
) -> SchedulingRequest:
|
||||
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
|
||||
request, _tokens = create_scheduling_request(
|
||||
self.session,
|
||||
@@ -108,6 +117,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
payload=SchedulingRequestCreateRequest(
|
||||
title="Response editing",
|
||||
status=status,
|
||||
allow_participant_updates=allow_participant_updates,
|
||||
calendar=SchedulingCalendarPreferences(),
|
||||
slots=[
|
||||
SchedulingCandidateSlotInput(
|
||||
@@ -136,8 +146,16 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
request.id,
|
||||
SchedulingAvailabilityResponseRequest(
|
||||
answers=[
|
||||
SchedulingAvailabilityAnswerInput(slot_id=request.slots[0].id, value="available"),
|
||||
SchedulingAvailabilityAnswerInput(slot_id=request.slots[1].id, value="maybe"),
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[0].id,
|
||||
value="available",
|
||||
option_revision=scheduling_slot_revision(request.slots[0]),
|
||||
),
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[1].id,
|
||||
value="maybe",
|
||||
option_revision=scheduling_slot_revision(request.slots[1]),
|
||||
),
|
||||
]
|
||||
),
|
||||
session=self.session,
|
||||
@@ -227,7 +245,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
request_id=request.id,
|
||||
slot_id=slot.id,
|
||||
payload=SchedulingCandidateSlotUpdateRequest(
|
||||
start_at=original_start + timedelta(minutes=15),
|
||||
start_at=original_start.replace(tzinfo=timezone.utc) + timedelta(minutes=15),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -249,6 +267,115 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
self.assertIsNotNone(cancelled.cancelled_at)
|
||||
self.assertEqual(poll.status, "draft")
|
||||
|
||||
def test_fully_invalidated_response_becomes_unanswered(self) -> None:
|
||||
request = self._request()
|
||||
alice = self._principal(
|
||||
"alice-account",
|
||||
email="alice@example.test",
|
||||
scopes={RESPOND_SCOPE},
|
||||
)
|
||||
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=alice,
|
||||
)
|
||||
|
||||
api_update_scheduling_candidate_slot(
|
||||
request.id,
|
||||
request.slots[0].id,
|
||||
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
|
||||
session=self.session,
|
||||
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
|
||||
)
|
||||
current = api_get_my_scheduling_availability(
|
||||
request.id,
|
||||
session=self.session,
|
||||
principal=alice,
|
||||
)
|
||||
|
||||
self.assertFalse(current.has_response)
|
||||
self.assertEqual(current.answers, [])
|
||||
self.assertEqual(request.participants[0].status, "invited")
|
||||
self.assertIsNone(request.participants[0].responded_at)
|
||||
self.assertEqual(
|
||||
scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id)["response_count"],
|
||||
0,
|
||||
)
|
||||
|
||||
def test_option_change_is_blocked_when_response_updates_are_disabled(self) -> None:
|
||||
request = self._request(allow_participant_updates=False)
|
||||
self._submit_both(request)
|
||||
original_label = request.slots[0].label
|
||||
|
||||
with self.assertRaisesRegex(SchedulingError, "response updates are disabled"):
|
||||
update_scheduling_candidate_slot(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
request_id=request.id,
|
||||
slot_id=request.slots[0].id,
|
||||
payload=SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
|
||||
)
|
||||
|
||||
self.assertEqual(request.slots[0].label, original_label)
|
||||
response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one()
|
||||
self.assertEqual(len(response.answers), 2)
|
||||
|
||||
def test_stale_option_revision_is_rejected_without_writing_a_response(self) -> None:
|
||||
request = self._request()
|
||||
stale_revision = scheduling_slot_revision(request.slots[0])
|
||||
api_update_scheduling_candidate_slot(
|
||||
request.id,
|
||||
request.slots[0].id,
|
||||
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
|
||||
session=self.session,
|
||||
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
|
||||
)
|
||||
|
||||
with self.assertRaises(HTTPException) as stale:
|
||||
api_submit_scheduling_availability(
|
||||
request.id,
|
||||
SchedulingAvailabilityResponseRequest(
|
||||
answers=[
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[0].id,
|
||||
value="available",
|
||||
option_revision=stale_revision,
|
||||
)
|
||||
]
|
||||
),
|
||||
session=self.session,
|
||||
principal=self._principal(
|
||||
"alice-account",
|
||||
email="alice@example.test",
|
||||
scopes={RESPOND_SCOPE},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(stale.exception.status_code, 409)
|
||||
self.assertEqual(self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).count(), 0)
|
||||
|
||||
def test_slot_inputs_require_aware_datetimes_and_known_timezones(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
SchedulingCandidateSlotInput(
|
||||
start_at=datetime(2026, 7, 20, 9),
|
||||
end_at=datetime(2026, 7, 20, 10),
|
||||
)
|
||||
with self.assertRaisesRegex(ValidationError, "valid IANA timezone"):
|
||||
SchedulingCandidateSlotInput(
|
||||
start_at=datetime(2026, 7, 20, 9, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 20, 10, tzinfo=timezone.utc),
|
||||
timezone="Mars/Olympus_Mons",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -44,6 +44,7 @@ from govoplan_scheduling.backend.router import (
|
||||
api_create_tentative_calendar_holds,
|
||||
api_decide_scheduling_request,
|
||||
api_evaluate_calendar_freebusy,
|
||||
api_get_my_scheduling_availability,
|
||||
api_get_scheduling_request,
|
||||
api_list_scheduling_requests,
|
||||
api_scheduling_summary,
|
||||
@@ -66,6 +67,7 @@ from govoplan_scheduling.backend.service import (
|
||||
open_scheduling_request,
|
||||
require_visible_scheduling_results,
|
||||
scheduling_request_summary,
|
||||
scheduling_slot_revision,
|
||||
update_scheduling_request,
|
||||
)
|
||||
from govoplan_scheduling.backend.runtime import configure_runtime
|
||||
@@ -825,7 +827,12 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
"attacker",
|
||||
email="alice@example.test",
|
||||
membership_id="alice-membership",
|
||||
scopes={SCHEDULING_READ_SCOPE, "poll:poll:read", "poll:response:write"},
|
||||
scopes={
|
||||
SCHEDULING_READ_SCOPE,
|
||||
SCHEDULING_RESPOND_SCOPE,
|
||||
"poll:poll:read",
|
||||
"poll:response:write",
|
||||
},
|
||||
)
|
||||
|
||||
response = api_submit_poll_response(
|
||||
@@ -842,9 +849,19 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
session=self.session,
|
||||
principal=attacker,
|
||||
)
|
||||
current = api_get_my_scheduling_availability(
|
||||
request.id,
|
||||
session=self.session,
|
||||
principal=attacker,
|
||||
)
|
||||
|
||||
self.assertNotIn("invitation_id", response.metadata)
|
||||
self.assertEqual([request.id], [item.id for item in listed.requests])
|
||||
self.assertTrue(current.has_response)
|
||||
self.assertEqual(
|
||||
[(answer.slot_id, answer.value) for answer in current.answers],
|
||||
[(request.slots[0].id, "available")],
|
||||
)
|
||||
alice = next(participant for participant in request.participants if participant.display_name == "Alice")
|
||||
bob = next(participant for participant in request.participants if participant.display_name == "Bob")
|
||||
self.assertEqual(alice.status, "responded")
|
||||
@@ -883,10 +900,12 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[0].id,
|
||||
value="available",
|
||||
option_revision=scheduling_slot_revision(request.slots[0]),
|
||||
),
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[1].id,
|
||||
value="maybe",
|
||||
option_revision=scheduling_slot_revision(request.slots[1]),
|
||||
),
|
||||
]
|
||||
),
|
||||
@@ -917,6 +936,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[0].id,
|
||||
value="unavailable",
|
||||
option_revision=scheduling_slot_revision(request.slots[0]),
|
||||
)
|
||||
]
|
||||
),
|
||||
@@ -969,10 +989,12 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[0].id,
|
||||
value="unavailable",
|
||||
option_revision=scheduling_slot_revision(request.slots[0]),
|
||||
),
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[1].id,
|
||||
value="maybe",
|
||||
option_revision=scheduling_slot_revision(request.slots[1]),
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user