from __future__ import annotations from datetime import datetime, timezone from types import SimpleNamespace import unittest from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.core.access import PrincipalRef from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.ownership import ( OwnershipActionDecision, OwnershipProviderRegistration, OwnershipSubjectRef, OwnershipTransfer, OwnershipTransferError, ) 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 from govoplan_core.db.session import get_session from govoplan_core.server.ownership import router class _Provider: def __init__(self) -> None: self.owner = OwnershipSubjectRef(type="user", id="owner-1") def current_owner(self, session, *, tenant_id: str, resource_id: str): if tenant_id == "tenant-1" and resource_id == "resource-1": return self.owner return None def authorize_ownership_action( self, session, *, tenant_id: str, resource_id: str, action: str, actor: OwnershipSubjectRef, current_owner: OwnershipSubjectRef, target_owner: OwnershipSubjectRef, ) -> OwnershipActionDecision: del session, tenant_id, resource_id if action in { "propose_transfer", "approve_requested_transfer", "cancel_transfer", }: allowed = actor.id == current_owner.id elif action in { "request_ownership", "accept_transfer", "decline_transfer", }: allowed = actor.id == target_owner.id elif action == "view_transfer": allowed = actor.id in {current_owner.id, target_owner.id} else: allowed = False return OwnershipActionDecision(allowed=allowed) def apply_owner( self, session, *, tenant_id: str, resource_id: str, expected_owner: OwnershipSubjectRef, target_owner: OwnershipSubjectRef, actor: OwnershipSubjectRef, reason: str | None, ) -> None: del session, tenant_id, resource_id, actor, reason if ( self.owner.type != expected_owner.type or self.owner.id != expected_owner.id ): raise OwnershipTransferError("Owner changed") self.owner = target_owner class OwnershipApiTests(unittest.TestCase): def setUp(self) -> None: self.runtime_context = get_runtime_context() clear_runtime() self.engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all( self.engine, tables=[OwnershipTransfer.__table__], ) self.session = sessionmaker( bind=self.engine, expire_on_commit=False, )() self.provider = _Provider() registry = PlatformRegistry() registry.register( ModuleManifest( id="example", name="Example", version="1.0.0", ownership_providers=( OwnershipProviderRegistration( resource_type="example", provider=self.provider, ), ), ) ) self.principal = _principal("owner-1") app = FastAPI() app.state.govoplan_registry = registry app.include_router(router, prefix="/api/v1") app.dependency_overrides[get_session] = lambda: self.session app.dependency_overrides[get_api_principal] = lambda: self.principal self.client = TestClient(app) def tearDown(self) -> None: self.client.close() 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_transfer_requires_target_acceptance_and_is_listable(self) -> None: created = self.client.post( "/api/v1/ownership/transfers", json={ "resource": { "module_id": "example", "resource_type": "example", "resource_id": "resource-1", }, "target_owner": {"type": "user", "id": "target-1"}, "idempotency_key": "api-transfer-1", "reason": "Handover", }, ) self.assertEqual(201, created.status_code, created.text) transfer_id = created.json()["id"] self.assertEqual( "awaiting_target_acceptance", created.json()["status"], ) self.assertEqual( ["started"], [item["action"] for item in created.json()["decisions"]], ) visible = self.client.get("/api/v1/ownership/transfers") self.assertEqual(200, visible.status_code, visible.text) self.assertEqual([transfer_id], [item["id"] for item in visible.json()["transfers"]]) self.principal = _principal("target-1") accepted = self.client.post( f"/api/v1/ownership/transfers/{transfer_id}/acceptance" ) self.assertEqual(200, accepted.status_code, accepted.text) self.assertEqual("completed", accepted.json()["status"]) self.assertEqual("target-1", self.provider.owner.id) self.assertEqual( ["started", "accepted"], [item["action"] for item in accepted.json()["decisions"]], ) def test_resource_module_must_match_registered_provider(self) -> None: response = self.client.post( "/api/v1/ownership/transfers", json={ "resource": { "module_id": "wrong-module", "resource_type": "example", "resource_id": "resource-1", }, "target_owner": {"type": "user", "id": "target-1"}, "idempotency_key": "wrong-provider-module", }, ) self.assertEqual(404, response.status_code, response.text) def _principal(user_id: str) -> ApiPrincipal: return ApiPrincipal( principal=PrincipalRef( account_id=f"account-{user_id}", membership_id=user_id, tenant_id="tenant-1", scopes=frozenset({"campaigns:campaign:share"}), ), account=SimpleNamespace(id=f"account-{user_id}"), user=SimpleNamespace(id=user_id), auth_session=SimpleNamespace(created_at=datetime.now(timezone.utc)), ) if __name__ == "__main__": unittest.main()