fix(poll): harden lifecycle policy boundaries

This commit is contained in:
2026-07-20 18:42:07 +02:00
parent 12b3175b07
commit 2c776437b4
5 changed files with 212 additions and 23 deletions

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
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
@@ -29,6 +31,8 @@ from govoplan_poll.backend.service import (
PollError,
create_poll,
list_poll_lifecycle_transitions,
poll_response,
poll_results_are_visible,
response_datetime,
submit_poll_response,
transition_poll,
@@ -131,7 +135,7 @@ class PollLifecycleTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "not allowed"):
engine.plan(current_status="draft", action="close")
def test_policy_is_injectable_without_changing_the_engine(self) -> None:
def test_injected_policy_governs_service_transition_and_projection(self) -> None:
rules = dict(DEFAULT_POLL_TRANSITION_POLICY.rules)
rules["close"] = PollTransitionRule(
action="close",
@@ -139,10 +143,38 @@ class PollLifecycleTests(unittest.TestCase):
target_status="closed",
)
custom_engine = PollTransitionEngine(PollTransitionPolicy(rules))
poll = self._poll()
plan = custom_engine.plan(current_status="draft", action="close")
projected = poll_response(poll, transition_engine=custom_engine)
close_action = next(item for item in projected["lifecycle_actions"] if item["action"] == "close")
result = transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="close",
transition_engine=custom_engine,
)
self.assertEqual(plan.to_status, "closed")
self.assertTrue(close_action["available"])
self.assertEqual(result.poll.status, "closed")
self.assertEqual(result.transition.from_status, "draft")
def test_only_draft_or_open_are_valid_initial_states(self) -> None:
for initial_status in ("closed", "decided", "archived"):
with self.subTest(initial_status=initial_status):
with self.assertRaises(ValidationError):
PollCreateRequest(title="Invalid initial state", kind="yes_no", status=initial_status)
bypassed_schema = PollCreateRequest(title="Invalid initial state", kind="yes_no").model_copy(
update={"status": "closed"}
)
with self.assertRaisesRegex(PollError, "initial status must be draft or open"):
create_poll(
self.session,
tenant_id="tenant-1",
user_id="owner",
payload=bypassed_schema,
)
def test_reopening_preserves_responses_close_history_and_previous_decision(self) -> None:
poll = self._poll()
@@ -180,7 +212,9 @@ class PollLifecycleTests(unittest.TestCase):
self.assertEqual([item.action for item in history], ["open", "close", "decide", "open"])
def test_direct_redecision_is_audited_and_exact_retry_is_idempotent(self) -> None:
poll = self._poll(status="closed")
poll = self._poll()
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
first = transition_poll(
self.session,
tenant_id="tenant-1",
@@ -213,7 +247,12 @@ class PollLifecycleTests(unittest.TestCase):
self.assertEqual(second.transition.previous_decision_option_id, first.transition.decision_option_id)
self.assertEqual(poll.decided_option_id, second.transition.decision_option_id)
self.assertEqual(
self.session.query(PollLifecycleTransition).filter(PollLifecycleTransition.poll_id == poll.id).count(),
self.session.query(PollLifecycleTransition)
.filter(
PollLifecycleTransition.poll_id == poll.id,
PollLifecycleTransition.action == "decide",
)
.count(),
2,
)
@@ -235,8 +274,9 @@ class PollLifecycleTests(unittest.TestCase):
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
def test_archive_and_unarchive_restore_status_and_append_audit_history(self) -> None:
poll = self._poll(status="closed")
poll.closed_at = poll.created_at
poll = self._poll()
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
response = PollResponse(
tenant_id="tenant-1",
poll_id=poll.id,
@@ -267,13 +307,119 @@ class PollLifecycleTests(unittest.TestCase):
self.assertEqual(poll.status, "closed")
self.assertIsNone(poll.archived_from_status)
self.assertIsNone(response.deleted_at)
self.assertEqual([item.action for item in poll.lifecycle_transitions], ["archive", "unarchive"])
self.assertEqual(
[item.action for item in poll.lifecycle_transitions],
["open", "close", "archive", "unarchive"],
)
def test_legacy_archive_without_restore_status_is_not_silently_unarchived(self) -> None:
poll = self._poll(status="archived")
def test_legacy_archive_without_origin_restores_to_draft_with_audit_marker(self) -> None:
poll = Poll(
tenant_id="tenant-1",
slug="legacy-archive",
title="Legacy archive",
kind="yes_no",
status="archived",
visibility="tenant",
result_visibility="after_close",
workflow_steps=[],
allow_anonymous=False,
allow_response_update=True,
min_choices=1,
)
self.session.add(poll)
self.session.flush()
with self.assertRaisesRegex(PollError, "no valid status to restore"):
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="unarchive")
restored = transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="unarchive",
)
self.assertEqual(restored.poll.status, "draft")
self.assertEqual(restored.transition.to_status, "draft")
self.assertEqual(
restored.transition.metadata_["legacy_restore_fallback"],
{
"reason": "missing_archived_from_status",
"restored_status": "draft",
},
)
def test_reopening_clears_only_an_expired_deadline_and_preserves_close_history(self) -> None:
poll = self._poll()
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
closed_at = poll.closed_at
expired_deadline = datetime.now(timezone.utc) - timedelta(days=1)
poll.closes_at = expired_deadline
reopened = transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="open",
)
self.assertEqual(reopened.poll.status, "open")
self.assertIsNone(reopened.poll.closes_at)
self.assertEqual(response_datetime(reopened.poll.closed_at), response_datetime(closed_at))
self.assertEqual(
reopened.transition.metadata_["cleared_expired_closes_at"],
expired_deadline.isoformat(),
)
submit_poll_response(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
payload=PollSubmitResponseRequest(
respondent_id="participant-after-reopen",
answers=[PollAnswerInput(option_key="yes")],
),
)
future_deadline = datetime.now(timezone.utc) + timedelta(days=1)
future_poll = self._poll(title="Future deadline")
future_poll.closes_at = future_deadline
opened = transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=future_poll.id,
action="open",
)
self.assertEqual(response_datetime(opened.poll.closes_at), future_deadline)
self.assertNotIn("cleared_expired_closes_at", opened.transition.metadata_)
def test_archiving_draft_or_open_poll_does_not_grant_after_close_visibility(self) -> None:
for initial_status in ("draft", "open"):
with self.subTest(initial_status=initial_status):
poll = self._poll(status=initial_status, title=f"Archive from {initial_status}")
transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="archive",
)
self.assertFalse(
poll_results_are_visible(
self.session,
poll=poll,
actor_ids=("participant",),
)
)
closed_poll = self._poll(title="Archive from closed")
transition_poll(self.session, tenant_id="tenant-1", poll_id=closed_poll.id, action="open")
transition_poll(self.session, tenant_id="tenant-1", poll_id=closed_poll.id, action="close")
transition_poll(self.session, tenant_id="tenant-1", poll_id=closed_poll.id, action="archive")
self.assertTrue(
poll_results_are_visible(
self.session,
poll=closed_poll,
actor_ids=("participant",),
)
)
def test_generic_api_returns_action_availability_history_and_actor(self) -> None:
poll = self._poll()