Files
govoplan-core/tests/test_ownership.py

431 lines
14 KiB
Python

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