feat: strengthen module contracts and shared WebUI runtime

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 53e947935a
commit 68328f3d8e
57 changed files with 4358 additions and 371 deletions

View File

@@ -230,7 +230,10 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ())
self.assertTrue(manifests["campaigns"].required_capabilities)
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail", "notifications", "addresses"))
self.assertEqual(
manifests["campaigns"].optional_dependencies,
("files", "mail", "notifications", "addresses", "postbox"),
)
self.assertEqual(manifests["dashboard"].dependencies, ())
self.assertTrue(manifests["dashboard"].required_capabilities)
self.assertEqual(manifests["docs"].dependencies, ())
@@ -587,7 +590,7 @@ class ModuleSystemTests(unittest.TestCase):
def test_enabled_module_permutations_register_expected_routes(self) -> None:
cases = (
("core_only", (), {"access"}, set()),
("core_only", (), set(), set()),
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
@@ -602,8 +605,14 @@ class ModuleSystemTests(unittest.TestCase):
app, _settings_obj = self._app_for_modules(enabled_modules)
route_paths = _route_paths(app)
self.assertIn("/api/v1/platform/modules", route_paths)
self.assertIn("/api/v1/auth/login", route_paths)
self.assertIn("/api/v1/admin/users", route_paths)
self.assertEqual(
"access" in expected_modules,
"/api/v1/auth/login" in route_paths,
)
self.assertEqual(
"access" in expected_modules,
"/api/v1/admin/users" in route_paths,
)
for prefix in ("/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"):
has_prefix = any(path == prefix or path.startswith(f"{prefix}/") for path in route_paths)
self.assertEqual(prefix in expected_prefixes, has_prefix, f"{name}: {prefix}")
@@ -613,12 +622,19 @@ class ModuleSystemTests(unittest.TestCase):
self.assertEqual(health.status_code, 200, health.text)
self.assertEqual({"status": "ok"}, health.json())
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(
401 if "access" in expected_modules else 503,
response.status_code,
response.text,
)
payload_modules = {
item.id
for item in app.state.govoplan_registry.manifests()
}
self.assertEqual(expected_modules, payload_modules)
def test_governance_template_routes_are_contributed_by_admin_module(self) -> None:
access_only_app, _settings_obj = self._app_for_modules(())
access_only_app, _settings_obj = self._app_for_modules(("access",))
access_only_paths = _route_paths(access_only_app)
self.assertIn("/api/v1/admin/users", access_only_paths)
self.assertNotIn("/api/v1/admin/system/governance-templates", access_only_paths)
@@ -827,6 +843,37 @@ finally:
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
registry.validate()
def test_registry_keeps_optional_auth_modules_access_free(self) -> None:
for modules in ((), ("poll",), ("evaluation",), ("scheduling",)):
with self.subTest(modules=modules):
registry = build_platform_registry(modules)
self.assertFalse(registry.has_module("access"))
def test_registry_prefers_selected_auth_provider_over_default(self) -> None:
provider = ModuleManifest(
id="alternative_auth",
name="Alternative auth",
version="test",
capability_factories={
"auth.principalResolver": lambda _context: object(),
},
)
consumer = ModuleManifest(
id="consumer",
name="Consumer",
version="test",
required_capabilities=("auth.principalResolver",),
)
registry = build_platform_registry(
("alternative_auth", "consumer"),
manifest_factories=(lambda: provider, lambda: consumer),
)
self.assertFalse(registry.has_module("access"))
self.assertTrue(registry.has_module("alternative_auth"))
self.assertTrue(registry.has_module("consumer"))
def test_registry_resolves_required_interface_ranges(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
@@ -903,7 +950,7 @@ finally:
plan = plan_desired_enabled_modules(("campaigns",), manifests)
self.assertEqual(("access", "admin", "campaigns"), plan.enabled_modules)
self.assertEqual((), plan.added_dependencies)
self.assertEqual(("access",), plan.added_dependencies)
def test_module_install_plan_is_saved_alongside_desired_state(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-", dir=_TEST_ROOT))
@@ -3300,7 +3347,10 @@ finally:
"version_min": "0.2.0",
"version_max_exclusive": "0.3.0",
}, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
self.assertEqual(
["files", "mail", "notifications", "addresses", "postbox"],
modules["campaigns"]["optional_dependencies"],
)
self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.9", modules["files"]["version"])
@@ -3946,8 +3996,10 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(response.status_code, 401, response.text)
payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "files"}, payload_modules)
def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None:
@@ -3979,8 +4031,10 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(response.status_code, 401, response.text)
payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "files"}, payload_modules)
def test_create_app_preserves_admin_when_configured_and_saved_state_is_older(self) -> None:
@@ -4012,8 +4066,10 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(response.status_code, 401, response.text)
payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "admin", "files"}, payload_modules)
def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None:
@@ -4023,27 +4079,37 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual(response.status_code, 503, response.text)
self.assertEqual((), lifecycle.active_module_ids())
self.assertFalse("/api/v1/files" in _route_paths(app))
result = lifecycle.apply_enabled_modules(("files",), migrate=False)
self.assertEqual(("files",), result.activated_modules)
self.assertEqual(("access", "files"), result.activated_modules)
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"access", "files"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual(response.status_code, 401, response.text)
self.assertEqual(("access", "files"), lifecycle.active_module_ids())
self.assertTrue("/api/v1/files" in _route_paths(app))
self.assertEqual(401, client.get("/api/v1/files").status_code)
result = lifecycle.apply_enabled_modules((), migrate=False)
self.assertEqual(("files",), result.deactivated_modules)
self.assertEqual(("access", "files"), result.deactivated_modules)
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual(response.status_code, 503, response.text)
self.assertEqual((), lifecycle.active_module_ids())
disabled_response = client.get("/api/v1/files")
self.assertEqual(404, disabled_response.status_code)
self.assertEqual("Module is disabled: files", disabled_response.json()["detail"])
def test_module_lifecycle_live_apply_defaults_by_environment(self) -> None:
app, settings = self._app_for_modules(())
lifecycle = app.state.govoplan_lifecycle
self.assertTrue(lifecycle.live_apply_enabled())
settings.app_env = "production"
self.assertFalse(lifecycle.live_apply_enabled())
settings.module_live_apply_enabled = True
self.assertTrue(lifecycle.live_apply_enabled())
def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
cases = (
@@ -4073,10 +4139,9 @@ finally:
def test_module_migrations_are_registered_only_for_enabled_modules(self) -> None:
core_plan = migration_metadata_plan(build_platform_registry(()))
self.assertEqual(1, len(core_plan.script_locations))
self.assertTrue(core_plan.script_locations[0].endswith("govoplan_access/backend/migrations/versions"))
self.assertEqual(1, len(core_plan.metadata))
self.assertEqual(("access",), tuple(item.module_id for item in core_plan.modules))
self.assertEqual((), core_plan.script_locations)
self.assertEqual((), core_plan.metadata)
self.assertEqual((), core_plan.modules)
files_plan = migration_metadata_plan(build_platform_registry(("files",)))
self.assertEqual(2, len(files_plan.script_locations))