feat(poll): enforce auditable lifecycle transitions

This commit is contained in:
2026-07-20 18:18:20 +02:00
parent f4d5cac40d
commit 65e2d1fd65
14 changed files with 1161 additions and 68 deletions

View File

@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.base import Base
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
from govoplan_poll.backend.router import (
api_create_poll,
api_get_poll,
@@ -39,7 +39,13 @@ class PollAuthorizationTests(unittest.TestCase):
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[Poll.__table__, PollOption.__table__, PollResponse.__table__, PollInvitation.__table__],
tables=[
Poll.__table__,
PollOption.__table__,
PollResponse.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
@@ -48,7 +54,13 @@ class PollAuthorizationTests(unittest.TestCase):
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[PollInvitation.__table__, PollResponse.__table__, PollOption.__table__, Poll.__table__],
tables=[
PollLifecycleTransition.__table__,
PollInvitation.__table__,
PollResponse.__table__,
PollOption.__table__,
Poll.__table__,
],
)
self.engine.dispose()

321
tests/test_lifecycle.py Normal file
View File

@@ -0,0 +1,321 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.base import Base
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollOption,
PollResponse,
)
from govoplan_poll.backend.router import api_poll_lifecycle, api_transition_poll
from govoplan_poll.backend.schemas import (
PollAnswerInput,
PollCreateRequest,
PollOptionInput,
PollSubmitResponseRequest,
PollTransitionRequest,
)
from govoplan_poll.backend.service import (
PollError,
create_poll,
list_poll_lifecycle_transitions,
response_datetime,
submit_poll_response,
transition_poll,
)
from govoplan_poll.backend.transitions import (
DEFAULT_POLL_TRANSITION_POLICY,
PollTransitionEngine,
PollTransitionPolicy,
PollTransitionRule,
)
WRITE_SCOPE = "poll:poll:write"
class PollLifecycleTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
Poll.__table__,
PollOption.__table__,
PollResponse.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[
PollLifecycleTransition.__table__,
PollInvitation.__table__,
PollResponse.__table__,
PollOption.__table__,
Poll.__table__,
],
)
self.engine.dispose()
def _poll(self, *, status: str = "draft", title: str = "Decision") -> Poll:
return create_poll(
self.session,
tenant_id="tenant-1",
user_id="owner",
payload=PollCreateRequest(
title=title,
kind="single_choice",
status=status,
visibility="tenant",
options=[
PollOptionInput(key="yes", label="Yes"),
PollOptionInput(key="no", label="No"),
],
),
)
@staticmethod
def _principal() -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="owner",
membership_id="owner-membership",
tenant_id="tenant-1",
scopes=frozenset({WRITE_SCOPE}),
display_name="Owner",
),
account=SimpleNamespace(id="owner"),
user=SimpleNamespace(id="owner-membership"),
)
def test_default_policy_exposes_every_allowed_and_rejected_transition(self) -> None:
expected = {
"draft": {"open", "archive"},
"open": {"draft", "close", "archive"},
"closed": {"open", "decide", "archive"},
"decided": {"open", "decide", "archive"},
"archived": {"unarchive"},
}
engine = PollTransitionEngine()
for status, allowed in expected.items():
archived_from_status = "closed" if status == "archived" else None
availability = engine.available_actions(
current_status=status,
archived_from_status=archived_from_status,
)
self.assertEqual({item.action for item in availability if item.available}, allowed)
for item in availability:
if item.available:
self.assertIsNone(item.reason)
else:
self.assertTrue(item.reason)
with self.assertRaisesRegex(ValueError, "not allowed"):
engine.plan(current_status="draft", action="close")
def test_policy_is_injectable_without_changing_the_engine(self) -> None:
rules = dict(DEFAULT_POLL_TRANSITION_POLICY.rules)
rules["close"] = PollTransitionRule(
action="close",
source_statuses=frozenset({"draft", "open"}),
target_status="closed",
)
custom_engine = PollTransitionEngine(PollTransitionPolicy(rules))
plan = custom_engine.plan(current_status="draft", action="close")
self.assertEqual(plan.to_status, "closed")
def test_reopening_preserves_responses_close_history_and_previous_decision(self) -> None:
poll = self._poll()
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
response = submit_poll_response(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
payload=PollSubmitResponseRequest(
respondent_id="participant",
answers=[PollAnswerInput(option_key="yes")],
),
)
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
first_closed_at = poll.closed_at
transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="decide",
option_key="yes",
)
decided_at = poll.decided_at
decided_option_id = poll.decided_option_id
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
self.assertEqual(poll.status, "open")
self.assertEqual(response_datetime(poll.closed_at), response_datetime(first_closed_at))
self.assertEqual(response_datetime(poll.decided_at), response_datetime(decided_at))
self.assertEqual(poll.decided_option_id, decided_option_id)
self.assertEqual(response.deleted_at, None)
self.assertEqual(response.answers[0]["option_key"], "yes")
history = list_poll_lifecycle_transitions(self.session, tenant_id="tenant-1", poll_id=poll.id)
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")
first = transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="decide",
option_key="yes",
idempotency_key="decision-1",
)
replay = transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="decide",
option_key="yes",
idempotency_key="decision-1",
)
second = transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="decide",
option_key="no",
idempotency_key="decision-2",
)
self.assertFalse(first.replayed)
self.assertTrue(replay.replayed)
self.assertEqual(replay.transition.id, first.transition.id)
self.assertFalse(second.replayed)
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(),
2,
)
with self.assertRaisesRegex(PollError, "different poll transition"):
transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="decide",
option_key="yes",
idempotency_key="decision-2",
)
def test_repeated_exact_transition_without_an_identity_is_rejected(self) -> None:
poll = self._poll()
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
with self.assertRaisesRegex(PollError, "not allowed"):
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
response = PollResponse(
tenant_id="tenant-1",
poll_id=poll.id,
respondent_id="participant",
answers=[{"option_key": "yes"}],
submitted_at=poll.created_at,
)
self.session.add(response)
self.session.flush()
archived = transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="archive",
idempotency_key="archive-1",
)
restored = transition_poll(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
action="unarchive",
idempotency_key="unarchive-1",
)
self.assertEqual(archived.transition.to_status, "archived")
self.assertEqual(restored.transition.to_status, "closed")
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"])
def test_legacy_archive_without_restore_status_is_not_silently_unarchived(self) -> None:
poll = self._poll(status="archived")
with self.assertRaisesRegex(PollError, "no valid status to restore"):
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="unarchive")
def test_generic_api_returns_action_availability_history_and_actor(self) -> None:
poll = self._poll()
principal = self._principal()
opened = api_transition_poll(
poll.id,
PollTransitionRequest(action="open"),
idempotency_key="open-api-1",
session=self.session,
principal=principal,
)
replay = api_transition_poll(
poll.id,
PollTransitionRequest(action="open"),
idempotency_key="open-api-1",
session=self.session,
principal=principal,
)
lifecycle = api_poll_lifecycle(poll.id, session=self.session, principal=principal)
self.assertEqual(opened.poll.status, "open")
self.assertFalse(opened.replayed)
self.assertTrue(replay.replayed)
self.assertEqual(len(lifecycle.history), 1)
self.assertEqual(lifecycle.history[0].actor_user_id, "owner-membership")
self.assertEqual(
{item.action for item in lifecycle.actions if item.available},
{"draft", "close", "archive"},
)
draft = self._poll(title="Draft cannot close")
with self.assertRaises(HTTPException) as rejected:
api_transition_poll(
draft.id,
PollTransitionRequest(action="close"),
idempotency_key=None,
session=self.session,
principal=principal,
)
self.assertEqual(rejected.exception.status_code, 400)
if __name__ == "__main__":
unittest.main()

View File

@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.base import Base
from govoplan_core.core.poll import PollResponseRef, PollResponseSubmissionProvider, PollSchedulingProvider
from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
from govoplan_poll.backend.schemas import (
PollAnswerInput,
PollCreateRequest,
@@ -33,7 +33,13 @@ class PollServiceTests(unittest.TestCase):
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[Poll.__table__, PollOption.__table__, PollResponse.__table__, PollInvitation.__table__],
tables=[
Poll.__table__,
PollOption.__table__,
PollResponse.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
@@ -42,7 +48,13 @@ class PollServiceTests(unittest.TestCase):
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[PollInvitation.__table__, PollResponse.__table__, PollOption.__table__, Poll.__table__],
tables=[
PollLifecycleTransition.__table__,
PollInvitation.__table__,
PollResponse.__table__,
PollOption.__table__,
Poll.__table__,
],
)
self.engine.dispose()