Complete effective assignment expiry lifecycle
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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 IdmOrganizationFunctionAssignment
|
||||
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__,
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -233,6 +233,26 @@ class AssignmentWorkflowTests(unittest.TestCase):
|
||||
lifecycle_event_types(before, after, now=now),
|
||||
)
|
||||
|
||||
def test_reactivation_of_elapsed_assignment_emits_expiry(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
item = assignment(
|
||||
is_active=False,
|
||||
valid_until=now - timedelta(minutes=1),
|
||||
)
|
||||
before = AssignmentSnapshot.from_assignment(item) # type: ignore[arg-type]
|
||||
after = plan_assignment_update( # type: ignore[arg-type]
|
||||
item,
|
||||
{"is_active": True},
|
||||
).after
|
||||
|
||||
self.assertEqual(
|
||||
(
|
||||
"idm.function_assignment.changed.v1",
|
||||
"idm.function_assignment.expired.v1",
|
||||
),
|
||||
lifecycle_event_types(before, after, now=now),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+108
-4
@@ -14,17 +14,48 @@ from govoplan_organizations.backend.db import models as organization_models # n
|
||||
|
||||
|
||||
class StubIdentityDirectory:
|
||||
def __init__(self, identities: tuple[IdentityRef, ...] = ()) -> None:
|
||||
self.identities = identities
|
||||
|
||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||
return None
|
||||
return next(
|
||||
(item for item in self.identities if item.id == identity_id),
|
||||
None,
|
||||
)
|
||||
|
||||
def identity_for_account(self, account_id: str) -> IdentityRef | None:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
item
|
||||
for item in self.identities
|
||||
if account_id in item.account_ids
|
||||
or item.primary_account_id == account_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def identities_for_accounts(self, account_ids: tuple[str, ...]) -> tuple[IdentityRef, ...]:
|
||||
return ()
|
||||
requested = set(account_ids)
|
||||
return tuple(
|
||||
item
|
||||
for item in self.identities
|
||||
if requested.intersection(item.account_ids)
|
||||
or item.primary_account_id in requested
|
||||
)
|
||||
|
||||
def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]:
|
||||
return ()
|
||||
identity = self.get_identity(identity_id)
|
||||
if identity is None:
|
||||
return ()
|
||||
return tuple(
|
||||
IdentityAccountLinkRef(
|
||||
id=f"{identity_id}:{account_id}",
|
||||
identity_id=identity_id,
|
||||
account_id=account_id,
|
||||
is_primary=account_id == identity.primary_account_id,
|
||||
)
|
||||
for account_id in identity.account_ids
|
||||
)
|
||||
|
||||
|
||||
class StubOrganizationDirectory:
|
||||
@@ -227,6 +258,79 @@ class IdmDirectoryDelegationTests(unittest.TestCase):
|
||||
tenant_id="tenant-2",
|
||||
)
|
||||
|
||||
def test_batch_accounts_preserve_account_and_subunit_provenance(self) -> None:
|
||||
self.directory = SqlIdmDirectory(
|
||||
identities=StubIdentityDirectory(
|
||||
(
|
||||
IdentityRef(
|
||||
id="identity-shared",
|
||||
primary_account_id="account-1",
|
||||
account_ids=("account-1", "account-2"),
|
||||
),
|
||||
)
|
||||
), # type: ignore[arg-type]
|
||||
organizations=StubOrganizationDirectory(), # type: ignore[arg-type]
|
||||
)
|
||||
broad = IdmOrganizationFunctionAssignment(
|
||||
id="broad",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-shared",
|
||||
account_id=None,
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
applies_to_subunits=True,
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
account_specific = IdmOrganizationFunctionAssignment(
|
||||
id="account-specific",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-shared",
|
||||
account_id="account-2",
|
||||
function_id="function-2",
|
||||
organization_unit_id="unit-1",
|
||||
source="governance",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
simultaneous = IdmOrganizationFunctionAssignment(
|
||||
id="second-incumbent",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-second",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
with self.database.session() as session:
|
||||
session.add_all((broad, account_specific, simultaneous))
|
||||
session.commit()
|
||||
|
||||
resolved = self.directory.organization_function_assignments_for_accounts(
|
||||
("account-1", "account-2", "missing"),
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
self.assertEqual(("broad",), tuple(item.id for item in resolved["account-1"]))
|
||||
self.assertEqual(
|
||||
("broad", "account-specific"),
|
||||
tuple(item.id for item in resolved["account-2"]),
|
||||
)
|
||||
self.assertEqual((), resolved["missing"])
|
||||
self.assertTrue(resolved["account-1"][0].applies_to_subunits)
|
||||
self.assertEqual("governance", resolved["account-2"][1].source)
|
||||
|
||||
incumbency = self.directory.organization_function_incumbencies(
|
||||
("function-1",),
|
||||
tenant_id="tenant-1",
|
||||
)["function-1"]
|
||||
self.assertEqual(
|
||||
("broad", "second-incumbent"),
|
||||
tuple(item.id for item in incumbency.assignments),
|
||||
)
|
||||
self.assertFalse(incumbency.vacant)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user