from __future__ import annotations from datetime import datetime, timedelta, timezone import unittest from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.ownership import ( OwnershipActionDecision, OwnershipIdempotencyConflict, OwnershipProviderRegistration, OwnershipResourceRef, OwnershipSubjectRef, OwnershipTransfer, OwnershipTransferError, OwnershipTransferExpired, OwnershipTransferPolicy, OwnershipTransferStatus, accept_ownership_transfer, approve_administrative_recovery, approve_ownership_request, execute_administrative_recovery, request_ownership, start_administrative_recovery, start_owner_initiated_transfer, ) from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.runtime import ( clear_runtime, configure_runtime, get_runtime_context, ) from govoplan_core.db.base import Base NOW = datetime(2026, 7, 30, 10, 0, tzinfo=timezone.utc) RESOURCE = OwnershipResourceRef( module_id="campaigns", resource_type="campaign", resource_id="campaign-1", ) OWNER = OwnershipSubjectRef(type="account", id="owner-1") TARGET = OwnershipSubjectRef(type="account", id="target-1") class _OwnershipProvider: def __init__(self) -> None: self.owner = OWNER self.applied: list[tuple[OwnershipSubjectRef, OwnershipSubjectRef]] = [] def current_owner(self, session, *, tenant_id: str, resource_id: str): if tenant_id != "tenant-1" or resource_id != RESOURCE.resource_id: return None return self.owner def authorize_ownership_action( self, session, *, tenant_id: str, resource_id: str, action: str, actor: OwnershipSubjectRef, current_owner: OwnershipSubjectRef, target_owner: OwnershipSubjectRef, ) -> OwnershipActionDecision: allowed = False if action in {"propose_transfer", "approve_requested_transfer"}: allowed = actor.id == current_owner.id elif action == "request_ownership": allowed = actor.id == target_owner.id elif action == "accept_transfer": allowed = actor.id == target_owner.id elif action == "accept_group_transfer": allowed = actor.id == "group-manager-1" elif action in { "request_recovery", "approve_recovery", "execute_recovery", }: allowed = actor.id.startswith("recovery-officer-") elif action in {"decline_transfer", "cancel_transfer"}: allowed = actor.id in {current_owner.id, target_owner.id} return OwnershipActionDecision( allowed=allowed, reason=None if allowed else f"Not authorized for {action}", ) def apply_owner( self, session, *, tenant_id: str, resource_id: str, expected_owner: OwnershipSubjectRef, target_owner: OwnershipSubjectRef, actor: OwnershipSubjectRef, reason: str | None, ) -> None: if self.owner != expected_owner: raise OwnershipTransferError("Owner changed while transfer was pending") self.applied.append((expected_owner, target_owner)) self.owner = target_owner class OwnershipTransferTests(unittest.TestCase): def setUp(self) -> None: self.runtime_context = get_runtime_context() clear_runtime() self.engine = create_engine("sqlite:///:memory:") Base.metadata.create_all( self.engine, tables=[OwnershipTransfer.__table__], ) self.Session = sessionmaker(bind=self.engine, expire_on_commit=False) self.session: Session = self.Session() self.provider = _OwnershipProvider() def tearDown(self) -> None: self.session.close() Base.metadata.drop_all( self.engine, tables=[OwnershipTransfer.__table__], ) self.engine.dispose() if self.runtime_context is not None: configure_runtime(self.runtime_context) def test_owner_initiated_transfer_requires_target_acceptance(self) -> None: transfer = start_owner_initiated_transfer( self.session, tenant_id="tenant-1", resource=RESOURCE, provider=self.provider, actor=OWNER, target_owner=TARGET, idempotency_key="owner-transfer-1", now=NOW, ) self.assertEqual( transfer.status, OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value, ) self.assertEqual(self.provider.owner, OWNER) accept_ownership_transfer( self.session, transfer=transfer, provider=self.provider, actor=TARGET, now=NOW + timedelta(hours=1), ) self.assertEqual(transfer.status, OwnershipTransferStatus.COMPLETED.value) self.assertEqual(self.provider.owner, TARGET) self.assertEqual(transfer.target_accepted_by_id, TARGET.id) self.assertEqual( ["started", "accepted"], [decision["action"] for decision in transfer.decisions], ) def test_requested_transfer_requires_owner_then_requester(self) -> None: transfer = request_ownership( self.session, tenant_id="tenant-1", resource=RESOURCE, provider=self.provider, actor=TARGET, idempotency_key="request-transfer-1", now=NOW, ) self.assertEqual( transfer.status, OwnershipTransferStatus.AWAITING_OWNER_APPROVAL.value, ) approve_ownership_request( self.session, transfer=transfer, provider=self.provider, actor=OWNER, now=NOW + timedelta(minutes=5), ) self.assertEqual( transfer.status, OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value, ) self.assertEqual(self.provider.owner, OWNER) accept_ownership_transfer( self.session, transfer=transfer, provider=self.provider, actor=TARGET, now=NOW + timedelta(minutes=10), ) self.assertEqual(self.provider.owner, TARGET) def test_idempotency_key_replays_only_the_same_request(self) -> None: first = start_owner_initiated_transfer( self.session, tenant_id="tenant-1", resource=RESOURCE, provider=self.provider, actor=OWNER, target_owner=TARGET, idempotency_key="same-key", now=NOW, ) replay = start_owner_initiated_transfer( self.session, tenant_id="tenant-1", resource=RESOURCE, provider=self.provider, actor=OWNER, target_owner=TARGET, idempotency_key="same-key", now=NOW, ) self.assertEqual(replay.id, first.id) with self.assertRaises(OwnershipIdempotencyConflict): start_owner_initiated_transfer( self.session, tenant_id="tenant-1", resource=RESOURCE, provider=self.provider, actor=OWNER, target_owner=OwnershipSubjectRef(type="account", id="other"), idempotency_key="same-key", now=NOW, ) def test_group_target_acceptance_uses_dedicated_authority(self) -> None: group = OwnershipSubjectRef(type="group", id="group-1") transfer = start_owner_initiated_transfer( self.session, tenant_id="tenant-1", resource=RESOURCE, provider=self.provider, actor=OWNER, target_owner=group, idempotency_key="group-transfer", now=NOW, ) accept_ownership_transfer( self.session, transfer=transfer, provider=self.provider, actor=OwnershipSubjectRef(type="account", id="group-manager-1"), now=NOW + timedelta(minutes=10), ) self.assertEqual(self.provider.owner, group) self.assertEqual(transfer.target_accepted_by_id, "group-manager-1") def test_standard_recovery_requires_quorum_and_delay(self) -> None: policy = OwnershipTransferPolicy() target = OwnershipSubjectRef(type="account", id="recovered-owner") transfer = start_administrative_recovery( self.session, tenant_id="tenant-1", resource=RESOURCE, provider=self.provider, actor=OwnershipSubjectRef( type="account", id="recovery-officer-1", recently_authenticated=True, ), target_owner=target, idempotency_key="recovery-1", reason="Owner is unavailable", policy=policy, now=NOW, ) self.assertFalse(transfer.metadata_["encryption_keys_included"]) approve_administrative_recovery( self.session, transfer=transfer, provider=self.provider, actor=OwnershipSubjectRef( type="account", id="recovery-officer-1", recently_authenticated=True, ), now=NOW, ) self.assertEqual( transfer.status, OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS.value, ) approve_administrative_recovery( self.session, transfer=transfer, provider=self.provider, actor=OwnershipSubjectRef( type="account", id="recovery-officer-2", recently_authenticated=True, ), now=NOW + timedelta(minutes=1), ) self.assertEqual( transfer.status, OwnershipTransferStatus.RECOVERY_SCHEDULED.value, ) with self.assertRaisesRegex( OwnershipTransferError, "delay has not elapsed", ): execute_administrative_recovery( self.session, transfer=transfer, provider=self.provider, actor=OwnershipSubjectRef( type="account", id="recovery-officer-3", recently_authenticated=True, ), now=NOW + timedelta(hours=23), ) execute_administrative_recovery( self.session, transfer=transfer, provider=self.provider, actor=OwnershipSubjectRef( type="account", id="recovery-officer-3", recently_authenticated=True, ), now=NOW + timedelta(hours=24), ) self.assertEqual(self.provider.owner, target) self.assertEqual( [ "started", "recovery_approved", "recovery_approved", "recovery_executed", ], [decision["action"] for decision in transfer.decisions], ) self.assertEqual( 2, transfer.decisions[0]["details"]["required_approvals"], ) def test_standard_recovery_rejects_stale_authentication_context(self) -> None: with self.assertRaisesRegex( PermissionError, "recent authentication", ): start_administrative_recovery( self.session, tenant_id="tenant-1", resource=RESOURCE, provider=self.provider, actor=OwnershipSubjectRef( type="account", id="recovery-officer-1", ), target_owner=OwnershipSubjectRef( type="account", id="recovered-owner", ), idempotency_key="stale-recovery", reason="Owner is unavailable", now=NOW, ) def test_expiry_is_recorded_before_a_late_decision_is_rejected(self) -> None: transfer = start_owner_initiated_transfer( self.session, tenant_id="tenant-1", resource=RESOURCE, provider=self.provider, actor=OWNER, target_owner=TARGET, idempotency_key="expiring-transfer", expiry_days=1, now=NOW, ) with self.assertRaises(OwnershipTransferExpired): accept_ownership_transfer( self.session, transfer=transfer, provider=self.provider, actor=TARGET, now=NOW + timedelta(days=2), ) self.session.commit() self.assertEqual( OwnershipTransferStatus.EXPIRED.value, transfer.status, ) self.assertEqual( ["started", "expired"], [decision["action"] for decision in transfer.decisions], ) def test_registry_resolves_one_provider_per_resource_type(self) -> None: registry = PlatformRegistry() registry.register( ModuleManifest( id="campaigns", name="Campaigns", version="1.0.0", ownership_providers=( OwnershipProviderRegistration( resource_type="campaign", provider=self.provider, ), ), ) ) self.assertIs(registry.ownership_provider("CAMPAIGN"), self.provider) if __name__ == "__main__": unittest.main()