from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime, timedelta import unittest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from govoplan_core.core.approvals import ( ApprovalActorSelector, ApprovalDecisionCommand, ApprovalRequestCreateCommand, ApprovalStepDefinition, ApprovalTemplateCreateCommand, ) from govoplan_core.db.base import Base from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests DIGEST = "a" * 64 @dataclass class Principal: tenant_id: str account_id: str group_ids: tuple[str, ...] = () role_ids: tuple[str, ...] = () function_assignment_ids: tuple[str, ...] = () acting_for_account_id: str | None = None acting_assignment_id: str | None = None def request_command() -> ApprovalRequestCreateCommand: return ApprovalRequestCreateCommand( title="Approve Campaign delivery", subject_module="campaigns", subject_type="campaign_version", subject_id="campaign-1", subject_version="version-7", subject_digest=DIGEST, steps=( ApprovalStepDefinition( key="review", label="Review", selectors=(ApprovalActorSelector("group", "reviewers"),), ), ApprovalStepDefinition( key="release", label="Release", selectors=(ApprovalActorSelector("role", "senders"),), signature_required=True, ), ), separation_of_duties=True, unique_actors_across_steps=True, ) class ApprovalRuntimeTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all(self.engine) self.Session = sessionmaker(bind=self.engine) self.service = SqlApprovalRequests() self.requester = Principal("tenant-1", "requester") def tearDown(self) -> None: Base.metadata.drop_all(self.engine) self.engine.dispose() def test_sequential_chain_exact_subject_and_signature(self) -> None: with self.Session() as session: created = self.service.create_request( session, self.requester, command=request_command(), idempotency_key="create-1", ) review = self.service.decide( session, Principal("tenant-1", "reviewer", group_ids=("reviewers",)), request_id=created.id, command=ApprovalDecisionCommand("approved", "Reviewed.", 1, "review-1"), ) self.assertEqual(2, review.revision) current = self.service.get_request( session, self.requester, request_id=created.id ) self.assertEqual("release", current["current_step_key"]) with self.assertRaisesRegex(ApprovalStoreError, "signature"): self.service.decide( session, Principal("tenant-1", "sender", role_ids=("senders",)), request_id=created.id, command=ApprovalDecisionCommand( "approved", "Release.", 2, "release-no-signature" ), ) released = self.service.decide( session, Principal("tenant-1", "sender", role_ids=("senders",)), request_id=created.id, command=ApprovalDecisionCommand( "approved", "Release.", 2, "release-1", signature_ref={"provider": "signatures", "id": "sig-1"}, ), ) self.assertEqual(3, released.revision) check = self.service.check_approved( session, self.requester, request_id=created.id, subject_module="campaigns", subject_type="campaign_version", subject_id="campaign-1", subject_version="version-7", subject_digest=DIGEST, ) self.assertTrue(check.approved) with self.assertRaisesRegex(ApprovalStoreError, "exact requested subject"): self.service.check_approved( session, self.requester, request_id=created.id, subject_module="campaigns", subject_type="campaign_version", subject_id="campaign-1", subject_version="version-8", subject_digest=DIGEST, ) def test_separation_of_duties_rejection_replay_and_tenant_isolation(self) -> None: direct = ApprovalRequestCreateCommand( title="Direct review", subject_module="campaigns", subject_type="campaign_version", subject_id="campaign-1", subject_version="version-7", subject_digest=DIGEST, steps=( ApprovalStepDefinition( "review", "Review", (ApprovalActorSelector("account", "requester"),) ), ), ) with self.Session() as session: created = self.service.create_request( session, self.requester, command=direct, idempotency_key="create-direct" ) with self.assertRaisesRegex(ApprovalStoreError, "separation of duties"): self.service.decide( session, self.requester, request_id=created.id, command=ApprovalDecisionCommand("approved", "Self.", 1, "self-1"), ) self.assertIsNone( self.service.get_request( session, Principal("tenant-2", "requester"), request_id=created.id ) ) rejected_command = ApprovalRequestCreateCommand( title="Review", subject_module="cases", subject_type="case", subject_id="case-1", subject_version="1", subject_digest=DIGEST, steps=( ApprovalStepDefinition( "review", "Review", (ApprovalActorSelector("account", "reviewer"),), ), ), ) rejected = self.service.create_request( session, self.requester, command=rejected_command, idempotency_key="create-reject", ) receipt = self.service.decide( session, Principal("tenant-1", "reviewer"), request_id=rejected.id, command=ApprovalDecisionCommand( "rejected", "Insufficient evidence.", 1, "reject-1" ), ) replay = self.service.decide( session, Principal("tenant-1", "reviewer"), request_id=rejected.id, command=ApprovalDecisionCommand( "rejected", "Insufficient evidence.", 1, "reject-1" ), ) self.assertEqual(receipt.receipt_sha256, replay.receipt_sha256) self.assertTrue(replay.replayed) self.assertEqual( "rejected", self.service.get_request( session, self.requester, request_id=rejected.id )["state"], ) def test_due_step_escalates_explicitly(self) -> None: command = ApprovalRequestCreateCommand( title="Due review", subject_module="files", subject_type="file", subject_id="file-1", subject_version="1", subject_digest=DIGEST, steps=( ApprovalStepDefinition( "review", "Review", (ApprovalActorSelector("account", "reviewer"),), due_at=datetime.now(UTC) - timedelta(minutes=1), ), ), ) with self.Session() as session: created = self.service.create_request( session, self.requester, command=command, idempotency_key="create-due" ) escalated = self.service.escalate_due( session, self.requester, request_id=created.id, expected_revision=1, idempotency_key="escalate-1", ) self.assertEqual("escalated", escalated.state) def test_template_and_evidence_role_constraints_are_frozen(self) -> None: template_command = ApprovalTemplateCreateCommand( key="campaign-release", title="Campaign release", steps=( ApprovalStepDefinition( "release", "Release", (ApprovalActorSelector("role", "senders"),), forbidden_evidence_roles=("builder",), ), ), ) with self.Session() as session: draft = self.service.create_template( session, self.requester, command=template_command, idempotency_key="template-1", ) published = self.service.publish_template( session, self.requester, template_id=draft.id, expected_revision=1, idempotency_key="publish-1", ) request = ApprovalRequestCreateCommand( title="Approve exact execution", subject_module="campaigns", subject_type="campaign_execution", subject_id="campaign-1", subject_version="build-7", subject_digest=DIGEST, steps=(), evidence_actors={"builder": ("builder",)}, template_id=published.id, template_revision=published.revision, ) created = self.service.create_request( session, self.requester, command=request, idempotency_key="templated-request-1", ) with self.assertRaisesRegex(ApprovalStoreError, "builder actor"): self.service.decide( session, Principal("tenant-1", "builder", role_ids=("senders",)), request_id=created.id, command=ApprovalDecisionCommand( "approved", "Built and release attempted.", 1, "builder-release" ), ) receipt = self.service.decide( session, Principal("tenant-1", "sender", role_ids=("senders",)), request_id=created.id, command=ApprovalDecisionCommand( "approved", "Independent release.", 1, "sender-release" ), ) self.assertEqual( "role", receipt.authority_provenance["matched_selector"]["kind"] ) with self.assertRaisesRegex(ApprovalStoreError, "content digest"): self.service.check_approved( session, self.requester, request_id=created.id, subject_module="campaigns", subject_type="campaign_execution", subject_id="campaign-1", subject_version="build-7", subject_digest="b" * 64, ) if __name__ == "__main__": unittest.main()