feat(poll): enforce auditable lifecycle transitions
This commit is contained in:
@@ -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
321
tests/test_lifecycle.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user