Add governed module and interface controls

This commit is contained in:
2026-08-04 05:20:47 +02:00
parent 5bc7d748f8
commit d6e7c8b0b1
27 changed files with 1700 additions and 74 deletions
+293
View File
@@ -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()