from __future__ import annotations from dataclasses import replace import unittest from unittest.mock import patch from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import PrincipalRef from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.concurrency import RevisionConflictError from govoplan_core.core.organizations import OrganizationFunctionRef from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision from govoplan_core.core.workflows import WorkflowInstanceRef from govoplan_core.db.base import Base from govoplan_core.db.session import configure_database, reset_database from govoplan_identity.backend.db import models as identity_models # noqa: F401 from govoplan_idm.backend.api.v1.schemas import FunctionAssignmentChangeCreateRequest from govoplan_idm.backend.api.v1.function_changes import _change_item from govoplan_idm.backend.db.models import ( IdmFunctionAssignmentChange, IdmFunctionAssignmentChangeEvent, IdmOrganizationFunctionAssignment, IdmTenantSettings, ) from govoplan_idm.backend.function_assignment_changes import ( create_function_assignment_change, transition_function_assignment_change, ) from govoplan_organizations.backend.db import models as organization_models # noqa: F401 class _Policy: def resolve_function_assignment_action(self, session=None, *, request): del session steps = ("holder", "authority") context = request.context allowed = { "submit": bool(context.get("candidate_is_actor")), "approve_holder": bool(context.get("actor_is_holder")), "approve_authority": bool(context.get("actor_is_authority")), "accept_recipient": bool(context.get("candidate_is_actor")), "request_changes": bool( context.get("actor_is_holder") or context.get("actor_is_authority") ), "respond": bool( context.get("actor_is_initiator") or context.get("candidate_is_actor") ), "reject": bool( context.get("actor_is_holder") or context.get("actor_is_authority") ), "withdraw": bool(context.get("actor_is_initiator")), "recover": request.actor.account_id == "admin", "apply": bool(context.get("approvals_complete")), }.get(request.action, False) return FunctionAssignmentGovernanceDecision( allowed=allowed, reason=None if allowed else "Not eligible for this action.", profile="holder_with_authority_clearance", required_steps=steps, authority_function_id="authority-function", separation_of_duties=False, request_expiry_hours=24, ) class _Workflow: nodes = ("holder_review", "authority_review", "recipient_review") def __init__(self) -> None: self.instances: dict[str, WorkflowInstanceRef] = {} def start_standard(self, session, principal, *, request): del session, principal instance_id = f"workflow-{request.idempotency_key}" existing = self.instances.get(instance_id) if existing is not None: return replace(existing, replayed=True) reference = WorkflowInstanceRef( id=instance_id, tenant_id=request.tenant_id, definition_id="definition-1", definition_revision_id="revision-1", definition_revision=1, definition_hash="a" * 64, status="waiting", current_step_id="step-holder_review", current_node_id="holder_review", ) self.instances[instance_id] = reference return reference def resolve_current_step( self, session, principal, *, tenant_id, instance_id, resolution, ): del session, principal, tenant_id current = self.instances[instance_id] if resolution.expected_step_id != current.current_step_id: raise ValueError("Workflow current step changed.") if resolution.action == "changes": return current if resolution.action in {"reject", "cancel"}: result = replace( current, status="completed", current_step_id=None, current_node_id=None, ) else: index = self.nodes.index(current.current_node_id or "") + 1 if index >= len(self.nodes): result = replace( current, status="completed", current_step_id=None, current_node_id=None, ) else: node = self.nodes[index] result = replace( current, current_step_id=f"step-{node}", current_node_id=node, ) self.instances[instance_id] = result return result def get_instance(self, session, *, tenant_id, instance_id): del session, tenant_id return self.instances[instance_id] class _Registry: def __init__(self) -> None: self.policy = _Policy() self.workflow = _Workflow() def has_capability(self, name: str) -> bool: return name in { "policy.functionAssignmentGovernance", "workflow.orchestration", } def capability(self, name: str): if name == "policy.functionAssignmentGovernance": return self.policy if name == "workflow.orchestration": return self.workflow return None def principal(account_id: str, identity_id: str) -> ApiPrincipal: return ApiPrincipal( principal=PrincipalRef( account_id=account_id, membership_id=f"membership-{account_id}", tenant_id="tenant-1", identity_id=identity_id, scopes=frozenset({"idm:function_change:decide"}), ), account=object(), user=object(), ) def function() -> OrganizationFunctionRef: return OrganizationFunctionRef( id="target-function", tenant_id="tenant-1", organization_unit_id="unit-1", slug="target", name="Target function", settings={ "assignment_governance": { "request_profile": "holder_with_authority_clearance", "authority_function_id": "authority-function", } }, ) def payload() -> FunctionAssignmentChangeCreateRequest: return FunctionAssignmentChangeCreateRequest( kind="request", function_id="target-function", candidate_identity_id="candidate-identity", candidate_account_id="candidate", justification="The function is needed for the assigned work.", idempotency_key="request-1", ) class FunctionAssignmentChangeTests(unittest.TestCase): def setUp(self) -> None: self.database = configure_database("sqlite:///:memory:") Base.metadata.create_all( self.database.engine, tables=[ IdmOrganizationFunctionAssignment.__table__, IdmFunctionAssignmentChange.__table__, IdmFunctionAssignmentChangeEvent.__table__, IdmTenantSettings.__table__, ChangeSequenceEntry.__table__, ], ) self.registry = _Registry() def tearDown(self) -> None: reset_database(dispose=True) def _add_reviewer_assignments(self, session) -> None: session.add_all( ( IdmOrganizationFunctionAssignment( id="holder-assignment", tenant_id="tenant-1", identity_id="holder-identity", account_id="holder", function_id="target-function", organization_unit_id="unit-1", source="direct", is_active=True, settings={}, ), IdmOrganizationFunctionAssignment( id="authority-assignment", tenant_id="tenant-1", identity_id="authority-identity", account_id="authority", function_id="authority-function", organization_unit_id="unit-1", source="direct", is_active=True, settings={}, ), ) ) session.flush() def test_request_is_idempotent_and_applies_once_after_required_steps(self) -> None: with self.database.session() as session: self._add_reviewer_assignments(session) change, replayed = create_function_assignment_change( session, principal=principal("candidate", "candidate-identity"), registry=self.registry, function=function(), payload=payload(), ) session.flush() same, replay = create_function_assignment_change( session, principal=principal("candidate", "candidate-identity"), registry=self.registry, function=function(), payload=payload(), ) self.assertFalse(replayed) self.assertTrue(replay) self.assertEqual(change.id, same.id) self.assertEqual("awaiting_holder", change.state) transition_function_assignment_change( session, principal=principal("holder", "holder-identity"), registry=self.registry, change=change, function=function(), action="approve", base_revision=1, comment="Holder approval", evidence=(), ) self.assertEqual("awaiting_authority", change.state) transition_function_assignment_change( session, principal=principal("authority", "authority-identity"), registry=self.registry, change=change, function=function(), action="approve", base_revision=2, comment="Authority approval", evidence=(), ) session.commit() self.assertEqual("applied", change.state) self.assertIsNotNone(change.resulting_assignment_id) resulting = session.get( IdmOrganizationFunctionAssignment, change.resulting_assignment_id, ) self.assertEqual("candidate-identity", resulting.identity_id) self.assertEqual("governance", resulting.source) self.assertEqual( change.id, resulting.settings["governance"]["change_id"], ) self.assertEqual( 1, session.query(IdmOrganizationFunctionAssignment) .filter( IdmOrganizationFunctionAssignment.identity_id == "candidate-identity" ) .count(), ) def test_vacant_function_blocks_and_revision_claim_rejects_stale_action( self, ) -> None: with self.database.session() as session: change, _ = create_function_assignment_change( session, principal=principal("candidate", "candidate-identity"), registry=self.registry, function=function(), payload=payload(), ) session.flush() self.assertEqual("blocked", change.state) self.assertIn("vacant", change.outcome_reason) self._add_reviewer_assignments(session) change.state = "awaiting_holder" change.resource_revision = 2 session.flush() with self.assertRaises(RevisionConflictError): transition_function_assignment_change( session, principal=principal("holder", "holder-identity"), registry=self.registry, change=change, function=function(), action="approve", base_revision=1, comment=None, evidence=(), ) def test_historical_change_remains_readable_after_function_removal(self) -> None: with self.database.session() as session: change, _ = create_function_assignment_change( session, principal=principal("candidate", "candidate-identity"), registry=self.registry, function=function(), payload=payload(), ) session.flush() with patch( "govoplan_idm.backend.api.v1.function_changes._historical_function", return_value=None, ): item = _change_item( session, principal("candidate", "candidate-identity"), change, include_events=False, ) self.assertEqual(change.id, item.id) self.assertEqual([], item.available_actions) self.assertIn("no longer available", item.availability_reason) if __name__ == "__main__": unittest.main()