from __future__ import annotations import unittest from datetime import datetime, timedelta, timezone from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.events import EventBus, PlatformEvent, event_bus_context 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.assignment_lifecycle import SqlIdmAssignmentLifecycle from govoplan_idm.backend.db.models import ( IdmFunctionAssignmentChange, IdmFunctionAssignmentChangeEvent, IdmIdentityRelationship, IdmOrganizationFunctionAssignment, IdmTypedGroup, ) from govoplan_organizations.backend.db import models as organization_models # noqa: F401 class AssignmentExpiryTests(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__, IdmTypedGroup.__table__, IdmIdentityRelationship.__table__, ChangeSequenceEntry.__table__, ], ) self.lifecycle = SqlIdmAssignmentLifecycle() def tearDown(self) -> None: reset_database(dispose=True) @staticmethod def _assignment( assignment_id: str, *, tenant_id: str = "tenant-1", valid_until: datetime, active: bool = True, expired_event_at: datetime | None = None, ) -> IdmOrganizationFunctionAssignment: return IdmOrganizationFunctionAssignment( id=assignment_id, tenant_id=tenant_id, identity_id=f"identity-{assignment_id}", function_id="function-1", organization_unit_id="unit-1", source="direct", valid_until=valid_until, expired_event_at=expired_event_at, is_active=active, settings={}, ) def test_sweep_claims_due_assignments_once_and_preserves_provenance(self) -> None: boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc) with self.database.session() as session: session.add_all( ( self._assignment( "due", valid_until=boundary - timedelta(seconds=1), ), self._assignment( "future", valid_until=boundary + timedelta(seconds=1), ), self._assignment( "revoked", valid_until=boundary - timedelta(seconds=1), active=False, ), self._assignment( "already-emitted", valid_until=boundary - timedelta(seconds=1), expired_event_at=boundary - timedelta(minutes=1), ), self._assignment( "other-tenant", tenant_id="tenant-2", valid_until=boundary - timedelta(seconds=1), ), ) ) session.commit() events: list[PlatformEvent] = [] bus = EventBus() bus.subscribe("idm.function_assignment.expired.v1", events.append) with self.database.SessionLocal() as session, event_bus_context(bus): result = self.lifecycle.process_expired( session, tenant_id="tenant-1", effective_at=boundary, ) session.commit() self.assertEqual(1, result["selected"]) self.assertEqual(1, result["expired"]) self.assertEqual(["due"], result["assignment_ids"]) self.assertEqual(1, len(events)) self.assertEqual("system", events[0].actor.type) self.assertEqual("tenant-1", events[0].tenant.id) self.assertEqual("identity-due", events[0].payload["identity_id"]) self.assertEqual("function-1", events[0].payload["function_id"]) with self.database.session() as session: due = session.get(IdmOrganizationFunctionAssignment, "due") self.assertEqual(boundary, due.expired_event_at.replace(tzinfo=timezone.utc)) repeated = self.lifecycle.process_expired( session, tenant_id="tenant-1", effective_at=boundary, ) session.commit() self.assertEqual(0, repeated["expired"]) self.assertEqual(1, len(events)) def test_sweep_rollback_releases_marker_and_event(self) -> None: boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc) with self.database.session() as session: session.add( self._assignment( "rolled-back", valid_until=boundary - timedelta(seconds=1), ) ) session.commit() events: list[PlatformEvent] = [] bus = EventBus() bus.subscribe("idm.function_assignment.expired.v1", events.append) with self.database.SessionLocal() as session, event_bus_context(bus): self.lifecycle.process_expired(session, effective_at=boundary) session.rollback() self.assertEqual([], events) with self.database.session() as session: item = session.get(IdmOrganizationFunctionAssignment, "rolled-back") self.assertIsNone(item.expired_event_at) def test_limit_validation_is_bounded(self) -> None: with self.database.session() as session: for value in (0, 1001): with self.subTest(limit=value), self.assertRaises(ValueError): self.lifecycle.process_expired(session, limit=value) def test_sweep_expires_open_governed_changes_once(self) -> None: boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc) with self.database.session() as session: session.add( IdmFunctionAssignmentChange( id="change-due", tenant_id="tenant-1", kind="request", state="awaiting_holder", profile="holder_grant", function_id="function-1", organization_unit_id="unit-1", candidate_identity_id="identity-1", initiator_account_id="account-1", justification="Need the function", evidence=[], assignment_source="governance", required_steps=["holder"], completed_steps=[], policy_decision={}, idempotency_key="request-1", expires_at=boundary - timedelta(seconds=1), metadata_={}, ) ) session.commit() events: list[PlatformEvent] = [] bus = EventBus() bus.subscribe("idm.function_change.expired.v1", events.append) with self.database.SessionLocal() as session, event_bus_context(bus): result = self.lifecycle.process_expired( session, tenant_id="tenant-1", effective_at=boundary, ) session.commit() repeated = self.lifecycle.process_expired( session, tenant_id="tenant-1", effective_at=boundary, ) session.commit() self.assertEqual(1, result["expired_changes"]) self.assertEqual(["change-due"], result["change_ids"]) self.assertEqual(0, repeated["expired_changes"]) self.assertEqual(1, len(events)) with self.database.session() as session: change = session.get(IdmFunctionAssignmentChange, "change-due") self.assertEqual("expired", change.state) self.assertEqual(2, change.resource_revision) history = session.query(IdmFunctionAssignmentChangeEvent).all() self.assertEqual(["expired"], [item.action for item in history]) def test_sweep_escalates_due_review_once_without_substituting_approval(self) -> None: boundary = datetime(2026, 8, 22, 12, tzinfo=timezone.utc) with self.database.session() as session: session.add( IdmFunctionAssignmentChange( id="change-escalate", tenant_id="tenant-1", kind="grant", state="awaiting_holder", profile="holder_grant", function_id="function-1", organization_unit_id="unit-1", candidate_identity_id="identity-1", initiator_account_id="account-1", justification="Timed governed review", evidence=[], assignment_source="governance", required_steps=["holder"], completed_steps=[], policy_decision={ "escalation_rules": [ { "step": "holder", "target_function_id": "function-escalation", "timeout_hours": 4, } ] }, idempotency_key="grant-escalate-1", expires_at=boundary + timedelta(days=2), review_deadline_at=boundary - timedelta(seconds=1), escalation_target_function_id="function-escalation", metadata_={"step_approvals": {}}, ) ) session.commit() events: list[PlatformEvent] = [] audit_events: list[PlatformEvent] = [] bus = EventBus() bus.subscribe("idm.function_change.escalated.v1", events.append) bus.subscribe( "idm.function_assignment_change.escalated", audit_events.append, ) with self.database.SessionLocal() as session, event_bus_context(bus): result = self.lifecycle.process_expired( session, tenant_id="tenant-1", effective_at=boundary, ) session.commit() repeated = self.lifecycle.process_expired( session, tenant_id="tenant-1", effective_at=boundary, ) session.commit() self.assertEqual(["change-escalate"], result["escalated_change_ids"]) self.assertEqual(0, repeated["escalated_changes"]) self.assertEqual(1, len(events)) self.assertEqual(1, len(audit_events)) with self.database.session() as session: change = session.get(IdmFunctionAssignmentChange, "change-escalate") self.assertEqual("escalated", change.state) self.assertEqual("awaiting_holder", change.escalation_from_state) self.assertEqual("function-escalation", change.escalation_target_function_id) self.assertEqual([], change.completed_steps) self.assertIsNone(change.resulting_assignment_id) history = session.query(IdmFunctionAssignmentChangeEvent).all() self.assertEqual(["escalated"], [item.action for item in history]) self.assertFalse( history[0].details["automatic_approver_substitution"] ) def test_sweep_emits_relationship_expiry_once(self) -> None: boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc) with self.database.session() as session: session.add( IdmTypedGroup( id="group-1", tenant_id="tenant-1", key="eligible", name="Eligible", group_type="business_status", ) ) session.add( IdmIdentityRelationship( id="relationship-due", tenant_id="tenant-1", relationship_kind="member", subject_identity_id="identity-1", target_group_id="group-1", valid_until=boundary - timedelta(seconds=1), status="active", properties={}, provenance={}, ) ) session.commit() events: list[PlatformEvent] = [] bus = EventBus() bus.subscribe("idm.relationship.expired.v1", events.append) with self.database.SessionLocal() as session, event_bus_context(bus): result = self.lifecycle.process_expired( session, tenant_id="tenant-1", effective_at=boundary, ) session.commit() repeated = self.lifecycle.process_expired( session, tenant_id="tenant-1", effective_at=boundary, ) session.commit() self.assertEqual(["relationship-due"], result["relationship_ids"]) self.assertEqual(1, result["expired_relationships"]) self.assertEqual(0, repeated["expired_relationships"]) self.assertEqual(1, len(events)) self.assertEqual("identity-1", events[0].subject.id) with self.database.session() as session: item = session.get(IdmIdentityRelationship, "relationship-due") self.assertEqual(2, item.revision) self.assertIsNotNone(item.expired_event_at) if __name__ == "__main__": unittest.main()