Files
govoplan-idm/tests/test_assignment_expiry.py
T

268 lines
10 KiB
Python

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_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()