Add governed module and interface controls
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.lifecycle import require_module_active
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
ModuleEntitlementConflict,
|
||||
ModuleEntitlementError,
|
||||
tenant_module_entitlement_state,
|
||||
update_system_tenant_module_policy,
|
||||
update_tenant_module_selection,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import configure_database, get_database
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
|
||||
|
||||
class TenantModuleEntitlementTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.manifests = {
|
||||
"access": ModuleManifest(id="access", name="Access", version="test"),
|
||||
"admin": ModuleManifest(
|
||||
id="admin",
|
||||
name="Admin",
|
||||
version="test",
|
||||
dependencies=("access",),
|
||||
),
|
||||
"files": ModuleManifest(
|
||||
id="files",
|
||||
name="Files",
|
||||
version="test",
|
||||
dependencies=("access",),
|
||||
),
|
||||
"campaigns": ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="test",
|
||||
dependencies=("access", "files"),
|
||||
),
|
||||
"encryption": ModuleManifest(
|
||||
id="encryption",
|
||||
name="Encryption",
|
||||
version="test",
|
||||
),
|
||||
}
|
||||
|
||||
def test_unconfigured_tenant_preserves_current_module_visibility(self) -> None:
|
||||
state = tenant_module_entitlement_state({}, self.manifests)
|
||||
|
||||
self.assertFalse(state.configured)
|
||||
self.assertEqual(set(self.manifests), set(state.effective_modules))
|
||||
self.assertEqual({"access", "admin"}, set(state.forced_modules))
|
||||
|
||||
def test_system_policy_closes_dependencies_and_tenant_selection(self) -> None:
|
||||
settings, state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=("campaigns", "encryption"),
|
||||
forced_modules=("campaigns",),
|
||||
enabled_modules=("encryption",),
|
||||
expected_revision=0,
|
||||
)
|
||||
|
||||
self.assertEqual(1, state.revision)
|
||||
self.assertEqual(
|
||||
{"access", "admin", "files", "campaigns", "encryption"},
|
||||
set(state.available_modules),
|
||||
)
|
||||
self.assertEqual(
|
||||
{"access", "admin", "files", "campaigns"},
|
||||
set(state.forced_modules),
|
||||
)
|
||||
self.assertEqual({"encryption"}, set(state.selected_modules))
|
||||
self.assertEqual(set(self.manifests), set(state.effective_modules))
|
||||
self.assertIn("module_entitlements", settings)
|
||||
|
||||
def test_tenant_cannot_enable_system_unavailable_module(self) -> None:
|
||||
settings, _state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=("files",),
|
||||
forced_modules=(),
|
||||
enabled_modules=(),
|
||||
expected_revision=0,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
ModuleEntitlementError,
|
||||
"unavailable by system policy: encryption",
|
||||
):
|
||||
update_tenant_module_selection(
|
||||
settings,
|
||||
self.manifests,
|
||||
enabled_modules=("encryption",),
|
||||
expected_revision=1,
|
||||
)
|
||||
|
||||
def test_forced_modules_remain_effective_when_tenant_selection_is_empty(self) -> None:
|
||||
settings, _state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=("campaigns",),
|
||||
forced_modules=("campaigns",),
|
||||
enabled_modules=(),
|
||||
expected_revision=0,
|
||||
)
|
||||
_settings, state = update_tenant_module_selection(
|
||||
settings,
|
||||
self.manifests,
|
||||
enabled_modules=(),
|
||||
expected_revision=1,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{"access", "admin", "files", "campaigns"},
|
||||
set(state.effective_modules),
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
not item.tenant_can_toggle
|
||||
for item in state.modules
|
||||
if item.id in state.forced_modules
|
||||
)
|
||||
)
|
||||
|
||||
def test_inactive_runtime_module_is_selected_but_not_effective(self) -> None:
|
||||
settings, state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=("files", "encryption"),
|
||||
forced_modules=(),
|
||||
enabled_modules=("encryption",),
|
||||
expected_revision=0,
|
||||
runtime_active_modules=("access", "admin", "files"),
|
||||
)
|
||||
|
||||
self.assertIn("encryption", state.selected_modules)
|
||||
self.assertNotIn("encryption", state.effective_modules)
|
||||
encryption = next(item for item in state.modules if item.id == "encryption")
|
||||
self.assertIn("not active in the deployment", encryption.reason or "")
|
||||
self.assertIn("module_entitlements", settings)
|
||||
|
||||
def test_stale_revision_is_rejected(self) -> None:
|
||||
settings, _state = update_system_tenant_module_policy(
|
||||
{},
|
||||
self.manifests,
|
||||
available_modules=("files",),
|
||||
forced_modules=(),
|
||||
enabled_modules=("files",),
|
||||
expected_revision=0,
|
||||
)
|
||||
|
||||
with self.assertRaises(ModuleEntitlementConflict):
|
||||
update_tenant_module_selection(
|
||||
settings,
|
||||
self.manifests,
|
||||
enabled_modules=(),
|
||||
expected_revision=0,
|
||||
)
|
||||
|
||||
def test_malformed_document_fails_closed_to_protected_modules(self) -> None:
|
||||
state = tenant_module_entitlement_state(
|
||||
{"module_entitlements": {"revision": "invalid"}},
|
||||
self.manifests,
|
||||
)
|
||||
|
||||
self.assertEqual({"access", "admin"}, set(state.effective_modules))
|
||||
self.assertTrue(state.diagnostics)
|
||||
|
||||
|
||||
class TenantModuleEntitlementRouteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-entitlement-test-"))
|
||||
configure_database(f"sqlite:///{root / 'test.db'}")
|
||||
create_scope_tables(get_database().engine)
|
||||
self.manifests = (
|
||||
ModuleManifest(id="access", name="Access", version="test"),
|
||||
ModuleManifest(
|
||||
id="admin",
|
||||
name="Admin",
|
||||
version="test",
|
||||
dependencies=("access",),
|
||||
),
|
||||
ModuleManifest(
|
||||
id="files",
|
||||
name="Files",
|
||||
version="test",
|
||||
dependencies=("access",),
|
||||
),
|
||||
)
|
||||
self.registry = PlatformRegistry()
|
||||
for manifest in self.manifests:
|
||||
self.registry.register(manifest)
|
||||
settings, _state = update_system_tenant_module_policy(
|
||||
{},
|
||||
{manifest.id: manifest for manifest in self.manifests},
|
||||
available_modules=(),
|
||||
forced_modules=(),
|
||||
enabled_modules=(),
|
||||
expected_revision=0,
|
||||
)
|
||||
with get_database().session() as session:
|
||||
session.add(
|
||||
Tenant(
|
||||
id="tenant-1",
|
||||
slug="tenant-1",
|
||||
name="Tenant 1",
|
||||
settings=settings,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
self.principal = ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
def test_platform_metadata_excludes_tenant_unavailable_module(self) -> None:
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = self.registry
|
||||
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||
app.dependency_overrides[get_api_principal] = lambda: self.principal
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/modules")
|
||||
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual(
|
||||
{"access", "admin"},
|
||||
{item["id"] for item in response.json()["modules"]},
|
||||
)
|
||||
self.assertNotIn(
|
||||
"files",
|
||||
response.json()["module_entitlement"]["effective_modules"],
|
||||
)
|
||||
|
||||
def test_authenticated_module_route_is_hidden_when_tenant_unavailable(self) -> None:
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = self.registry
|
||||
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
|
||||
|
||||
@guarded.get("/files")
|
||||
def files_route():
|
||||
return {"ok": True}
|
||||
|
||||
app.include_router(guarded)
|
||||
with patch(
|
||||
"govoplan_core.core.lifecycle.get_api_principal",
|
||||
return_value=self.principal,
|
||||
), TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/files",
|
||||
headers={"Authorization": "Bearer test"},
|
||||
)
|
||||
|
||||
self.assertEqual(404, response.status_code, response.text)
|
||||
self.assertEqual(
|
||||
"Module is unavailable in the active tenant: files",
|
||||
response.json()["detail"],
|
||||
)
|
||||
|
||||
def test_unauthenticated_public_route_is_not_turned_into_login(self) -> None:
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = self.registry
|
||||
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
|
||||
|
||||
@guarded.get("/public-files")
|
||||
def public_files_route():
|
||||
return {"ok": True}
|
||||
|
||||
app.include_router(guarded)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/public-files")
|
||||
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
)
|
||||
from govoplan_core.core.platform_interfaces import (
|
||||
manifest_interface_catalog,
|
||||
manifest_interface_declarations,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
|
||||
|
||||
def _principal(*scopes: str) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id=None,
|
||||
tenant_id=None,
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
def _manifest() -> ModuleManifest:
|
||||
navigation = NavItem(path="/example", label="Example", icon="box")
|
||||
return ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.2.3",
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="example.reader", version="1.0.0"),
|
||||
),
|
||||
capability_factories={"example.reader": lambda _context: object()},
|
||||
nav_items=(navigation,),
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
routes=(
|
||||
FrontendRoute(path="/example", component="ExamplePage"),
|
||||
),
|
||||
nav_items=(navigation,),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PlatformInterfaceCatalogTests(unittest.TestCase):
|
||||
def test_manifest_declarations_have_stable_typed_keys(self) -> None:
|
||||
declarations = manifest_interface_declarations(_manifest())
|
||||
keys = {item.key for item in declarations}
|
||||
|
||||
self.assertIn("backend_capability:example.reader", keys)
|
||||
self.assertIn("provided_interface:example.reader", keys)
|
||||
self.assertIn("frontend_route:example.route.example", keys)
|
||||
self.assertIn("navigation:example.nav.example", keys)
|
||||
self.assertEqual(
|
||||
1,
|
||||
sum(item.key == "navigation:example.nav.example" for item in declarations),
|
||||
)
|
||||
|
||||
def test_catalog_digest_is_deterministic(self) -> None:
|
||||
first = manifest_interface_catalog(_manifest())
|
||||
second = manifest_interface_catalog(_manifest())
|
||||
|
||||
self.assertEqual(first["digest"], second["digest"])
|
||||
self.assertEqual("1", first["contract_version"])
|
||||
|
||||
def test_registry_rejects_conflicting_duplicate_navigation(self) -> None:
|
||||
manifest = _manifest()
|
||||
manifest = ModuleManifest(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
nav_items=manifest.nav_items,
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
nav_items=(NavItem(path="/example", label="Other"),),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(manifest)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "duplicate platform interface"):
|
||||
registry.validate()
|
||||
|
||||
def test_read_only_endpoint_requires_administrator_scope(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(_manifest())
|
||||
registry.validate()
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||
|
||||
app.dependency_overrides[get_api_principal] = lambda: _principal()
|
||||
with TestClient(app) as client:
|
||||
denied = client.get("/api/v1/platform/interface-catalog")
|
||||
self.assertEqual(403, denied.status_code)
|
||||
|
||||
app.dependency_overrides[get_api_principal] = lambda: _principal(
|
||||
"admin:module:read"
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/interface-catalog")
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.json()
|
||||
self.assertEqual("1", payload["contract_version"])
|
||||
self.assertEqual(["example"], [item["module_id"] for item in payload["modules"]])
|
||||
self.assertIn(
|
||||
"frontend_route:example.route.example",
|
||||
{
|
||||
item["key"]
|
||||
for item in payload["modules"][0]["declarations"]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user