Files
govoplan-core/tests/test_module_system.py
2026-07-07 16:00:38 +02:00

1427 lines
65 KiB
Python

from __future__ import annotations
import importlib
import base64
import json
import os
import shlex
import shutil
import subprocess
import sys
import tempfile
import textwrap
import tomllib
import unittest
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import Column, Integer, MetaData, Table, inspect
# Keep the default app import side effect from bootstrapping a development DB.
_TEST_ROOT = Path(tempfile.mkdtemp(prefix="govoplan-module-tests-"))
os.environ.setdefault("APP_ENV", "test")
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_ROOT / 'module-test.db'}")
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
os.environ.setdefault("CELERY_ENABLED", "false")
from fastapi.testclient import TestClient
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.core.module_management import (
ModuleInstallPlan,
ModuleInstallPlanItem,
ModuleManagementError,
desired_modules_after_package_plan,
module_install_plan_commands,
normalize_module_install_plan_item,
saved_desired_enabled_modules,
saved_module_install_plan,
save_desired_enabled_modules,
save_module_install_plan,
plan_desired_enabled_modules,
)
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, MaintenanceMode, saved_maintenance_mode, save_maintenance_mode
from govoplan_core.core.module_installer import (
cancel_module_installer_request,
claim_next_module_installer_request,
list_module_installer_runs,
list_module_installer_requests,
module_installer_daemon_status,
module_install_preflight,
module_installer_lock_status,
queue_module_installer_request,
read_module_installer_request,
read_module_installer_run,
retry_module_installer_request,
rollback_module_install_run,
run_module_install_plan,
supervise_module_install_plan,
update_module_installer_daemon_status,
update_module_installer_request,
)
from govoplan_core.core.module_license import module_license_decision, validate_module_license
from govoplan_core.core.module_package_catalog import (
module_package_catalog,
record_module_package_catalog_acceptance,
sign_module_package_catalog,
validate_module_package_catalog,
)
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleCompatibility, ModuleUninstallGuardResult
from govoplan_core.core.module_guards import drop_table_retirement_provider
from govoplan_core.core.modules import MigrationSpec, ModuleManifest
from govoplan_core.admin.models import SystemSettings
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.security.permissions import scope_grants
from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend, StorageBackendError
_MANIFEST_PROJECT_MODULES = {
"access": "govoplan_access",
"admin": "govoplan_admin",
"tenancy": "govoplan_tenancy",
"policy": "govoplan_policy",
"audit": "govoplan_audit",
"files": "govoplan_files",
"mail": "govoplan_mail",
"campaigns": "govoplan_campaign",
}
def _settings(root: Path) -> SimpleNamespace:
return SimpleNamespace(
app_env="test",
database_url=f"sqlite:///{root / 'test.db'}",
dev_mailbox_api_enabled=False,
file_storage_backend="local",
file_storage_local_root=str(root / "files"),
file_storage_local_fallback_roots="",
file_storage_s3_endpoint_url=None,
file_storage_s3_region=None,
file_storage_s3_bucket=None,
file_storage_s3_access_key_id=None,
file_storage_s3_secret_access_key=None,
s3_endpoint_url=None,
s3_region=None,
s3_bucket=None,
s3_access_key_id=None,
s3_secret_access_key=None,
file_upload_max_bytes=1024 * 1024,
file_upload_zip_max_bytes=10 * 1024 * 1024,
celery_enabled=False,
redis_url="redis://127.0.0.1:6379/15",
mock_mailbox_dir=str(root / "mock-mailbox"),
)
class ModuleSystemTests(unittest.TestCase):
@classmethod
def tearDownClass(cls) -> None:
shutil.rmtree(_TEST_ROOT, ignore_errors=True)
def _app_for_modules(self, modules: tuple[str, ...]):
root = Path(tempfile.mkdtemp(prefix="govoplan-module-app-", dir=_TEST_ROOT))
settings = _settings(root)
config = GovoplanServerConfig(
title="GovOPlaN Module Test",
version="test",
settings=settings,
enabled_modules=modules,
base_routers=(),
post_module_routers=(),
extra_routers=(),
lifespan=None,
app_configurators=(),
)
return create_app(config), settings
def _source_project_version(self, package_module: str) -> str:
module = importlib.import_module(package_module)
module_file = getattr(module, "__file__", None)
if not module_file:
self.fail(f"Could not locate source package for {package_module}")
for path in Path(module_file).resolve().parents:
pyproject_path = path / "pyproject.toml"
if pyproject_path.exists():
with pyproject_path.open("rb") as handle:
return str(tomllib.load(handle)["project"]["version"])
self.fail(f"Could not locate pyproject.toml for {package_module}")
def test_discovers_installed_core_and_product_module_manifests(self) -> None:
manifests = available_module_manifests()
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "files", "mail", "campaigns"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ("access",))
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
def test_access_manifest_contributes_admin_frontend_metadata(self) -> None:
manifests = available_module_manifests()
access = manifests["access"]
self.assertEqual(("/admin",), tuple(item.path for item in access.nav_items))
self.assertIsNotNone(access.frontend)
assert access.frontend is not None
self.assertEqual("@govoplan/access-webui", access.frontend.package_name)
self.assertEqual(("/admin",), tuple(route.path for route in access.frontend.routes))
self.assertEqual(("/admin",), tuple(item.path for item in access.frontend.nav_items))
self.assertTrue(access.frontend.routes[0].required_any)
self.assertEqual("admin", access.frontend.nav_items[0].icon)
def test_tenant_wildcard_grants_module_tenant_scopes(self) -> None:
self.assertTrue(scope_grants("tenant:*", "calendar:event:read"))
self.assertTrue(scopes_grant_compatible(["tenant:*"], "calendar:event:read"))
self.assertFalse(scope_grants("tenant:*", "system:settings:read"))
def test_platform_modules_own_live_legacy_model_tables(self) -> None:
from govoplan_admin.backend.db.models import GovernanceTemplate
from govoplan_audit.backend.db.models import AuditLog
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
from govoplan_core.admin.models import SystemSettings
from govoplan_core.db.base import Base
from govoplan_tenancy.backend.db.models import Tenant
self.assertIs(AccessBase, Base)
self.assertEqual("accounts", Account.__tablename__)
self.assertEqual("tenants", Tenant.__tablename__)
self.assertEqual("users", User.__tablename__)
self.assertEqual("api_keys", ApiKey.__tablename__)
self.assertEqual("auth_sessions", AuthSession.__tablename__)
self.assertEqual("audit_log", AuditLog.__tablename__)
self.assertEqual("governance_templates", GovernanceTemplate.__tablename__)
self.assertEqual("system_settings", SystemSettings.__tablename__)
self.assertIn("accounts", Base.metadata.tables)
self.assertIn("tenants", Base.metadata.tables)
self.assertNotIn("access_accounts", Base.metadata.tables)
self.assertNotIn("access_auth_sessions", Base.metadata.tables)
def test_module_manifest_versions_match_source_project_versions(self) -> None:
manifests = available_module_manifests()
for manifest_id, package_module in _MANIFEST_PROJECT_MODULES.items():
with self.subTest(manifest_id=manifest_id):
self.assertIn(manifest_id, manifests)
self.assertEqual(self._source_project_version(package_module), manifests[manifest_id].version)
def test_enabled_module_permutations_register_expected_routes(self) -> None:
cases = (
("core_only", (), {"tenancy", "access"}, set()),
("files_only", ("files",), {"tenancy", "access", "files"}, {"/api/v1/files"}),
("mail_only", ("mail",), {"tenancy", "access", "mail"}, {"/api/v1/mail"}),
("campaign_without_files_or_mail", ("campaigns",), {"tenancy", "access", "campaigns"}, {"/api/v1/campaigns"}),
("campaign_without_files", ("campaigns", "mail"), {"tenancy", "access", "campaigns", "mail"}, {"/api/v1/campaigns", "/api/v1/mail"}),
("campaign_with_files_but_no_mail", ("campaigns", "files"), {"tenancy", "access", "campaigns", "files"}, {"/api/v1/campaigns", "/api/v1/files"}),
("full_product", ("campaigns", "files", "mail"), {"tenancy", "access", "campaigns", "files", "mail"}, {"/api/v1/campaigns", "/api/v1/files", "/api/v1/mail"}),
)
for name, enabled_modules, expected_modules, expected_prefixes in cases:
with self.subTest(name=name):
app, _settings_obj = self._app_for_modules(enabled_modules)
route_paths = {getattr(route, "path", "") for route in app.routes}
self.assertIn("/api/v1/platform/modules", route_paths)
for prefix in ("/api/v1/campaigns", "/api/v1/files", "/api/v1/mail"):
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}")
with TestClient(app) as client:
health = client.get("/health")
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(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_paths = {getattr(route, "path", "") for route in access_only_app.routes}
self.assertIn("/api/v1/admin/users", access_only_paths)
self.assertNotIn("/api/v1/admin/system/governance-templates", access_only_paths)
admin_app, _settings_obj = self._app_for_modules(("admin",))
admin_paths = {getattr(route, "path", "") for route in admin_app.routes}
self.assertIn("/api/v1/admin/system/governance-templates", admin_paths)
def _run_physical_absence_probe(self, *, enabled_modules: tuple[str, ...], blocked_modules: tuple[str, ...]) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-absence-probe-", dir=_TEST_ROOT))
enabled = ",".join(("tenancy", "access", *enabled_modules)) if enabled_modules else "tenancy,access"
script = f"""
import importlib.abc
import json
import os
import sys
from pathlib import Path
os.environ["APP_ENV"] = "test"
os.environ["DATABASE_URL"] = {f"sqlite:///{root / 'probe.db'}"!r}
os.environ["DEV_BOOTSTRAP_ENABLED"] = "false"
os.environ["CELERY_ENABLED"] = "false"
os.environ["ENABLED_MODULES"] = {enabled!r}
BLOCKED = {blocked_modules!r}
class Blocker(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
for prefix in BLOCKED:
if fullname == prefix or fullname.startswith(prefix + "."):
raise ModuleNotFoundError(f"No module named {{prefix!r}}", name=prefix)
return None
sys.meta_path.insert(0, Blocker())
from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.registry import build_platform_registry
registry = build_platform_registry({enabled_modules!r})
config = GovoplanServerConfig(
title="absence probe",
version="test",
enabled_modules={enabled_modules!r},
base_routers=(),
post_module_routers=(),
extra_routers=(),
lifespan=None,
app_configurators=(),
)
app = create_app(config)
route_paths = [getattr(route, "path", "") for route in app.routes]
print(json.dumps({{"modules": [item.id for item in registry.manifests()], "routes": route_paths}}, sort_keys=True))
"""
result = subprocess.run(
[sys.executable, "-c", script],
cwd=str(Path(__file__).resolve().parents[1]),
env=os.environ.copy(),
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
self.fail(result.stderr or result.stdout)
def test_campaign_optional_integrations_follow_enabled_modules(self) -> None:
cases = (
(("campaigns",), False, False),
(("campaigns", "mail"), False, True),
(("campaigns", "files"), True, False),
(("campaigns", "files", "mail"), True, True),
)
for enabled_modules, files_enabled, mail_enabled in cases:
with self.subTest(enabled_modules=enabled_modules):
registry = build_platform_registry(enabled_modules)
self.assertTrue(registry.has_module("campaigns"))
self.assertEqual(files_enabled, registry.integration_enabled("campaigns", "files"))
self.assertEqual(mail_enabled, registry.integration_enabled("campaigns", "mail"))
def test_module_management_plan_adds_protected_modules_and_required_dependencies(self) -> None:
manifests = available_module_manifests()
plan = plan_desired_enabled_modules(("campaigns",), manifests)
self.assertEqual(("tenancy", "access", "admin", "campaigns"), plan.enabled_modules)
self.assertEqual((), 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))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4",
"webui_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.4",
}])
save_desired_enabled_modules(session, ("tenancy", "access", "files"))
session.commit()
self.assertEqual(("files",), tuple(item.module_id for item in plan.items))
with database.session() as session:
saved = saved_module_install_plan(session)
self.assertEqual(("files",), tuple(item.module_id for item in saved.items))
self.assertEqual((
"python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4'",
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.4' && npm install",
), module_install_plan_commands(saved))
def test_module_install_plan_rejects_local_dependency_refs(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-local-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
with self.assertRaises(ModuleManagementError):
save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "file:../govoplan-files",
}])
def test_module_install_plan_allows_uninstall_webui_package_without_ref(self) -> None:
item = normalize_module_install_plan_item({
"module_id": "files",
"action": "uninstall",
"python_package": "govoplan-files",
"webui_package": "@govoplan/files-webui",
})
plan = ModuleInstallPlan(items=(item,))
self.assertIsNone(item.webui_ref)
self.assertEqual((
"python -m pip uninstall -y govoplan-files",
"npm pkg delete dependencies.@govoplan/files-webui && npm install",
), module_install_plan_commands(plan))
def test_desired_modules_after_package_plan_applies_default_install_uninstall_state(self) -> None:
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="mail",
action="install",
python_package="govoplan-mail",
python_ref="govoplan-mail==0.1.4",
),
ModuleInstallPlanItem(
module_id="files",
action="uninstall",
python_package="govoplan-files",
),
))
self.assertEqual(
("tenancy", "access", "mail"),
desired_modules_after_package_plan(("tenancy", "access", "files"), plan),
)
def test_maintenance_mode_round_trips_through_system_settings(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-maintenance-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
saved = save_maintenance_mode(session, MaintenanceMode(enabled=True, message="Tagged module install"))
session.commit()
self.assertTrue(saved.enabled)
with database.session() as session:
mode = saved_maintenance_mode(session)
self.assertTrue(mode.enabled)
self.assertEqual("Tagged module install", mode.message)
self.assertEqual("system:maintenance:access", MAINTENANCE_ACCESS_SCOPE)
def test_module_installer_preflight_blocks_active_module_uninstall(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-guard-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "uninstall",
"python_package": "govoplan-files",
}])
session.commit()
preflight = module_install_preflight(
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access", "files"),
desired_enabled=("tenancy", "access", "files"),
maintenance_mode=True,
)
self.assertFalse(preflight.allowed)
self.assertIn("module_active", {issue.code for issue in preflight.issues})
self.assertIn("module_desired", {issue.code for issue in preflight.issues})
def test_module_installer_preflight_blocks_incompatible_manifest(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-compat-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
}])
session.commit()
available = dict(available_module_manifests())
available["files"] = replace(
available["files"],
compatibility=ModuleCompatibility(core_version_min="999.0.0"),
)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
maintenance_mode=True,
)
self.assertFalse(preflight.allowed)
self.assertIn("core_version_too_low", {issue.code for issue in preflight.issues})
self.assertTrue(preflight.checklist)
def test_module_installer_preflight_requires_python_package_for_install_rollback(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-install-rollback-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_ref": "govoplan-files==0.1.4",
}])
session.commit()
preflight = module_install_preflight(
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
maintenance_mode=True,
)
self.assertFalse(preflight.allowed)
self.assertIn("python_package_required", {issue.code for issue in preflight.issues})
def test_module_installer_preflight_uses_module_uninstall_guards(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-module-guard-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "uninstall",
"python_package": "govoplan-files",
}])
session.commit()
available = dict(available_module_manifests())
migration = available["files"].migration_spec
self.assertIsNotNone(migration)
assert migration is not None
available["files"] = replace(
available["files"],
migration_spec=replace(migration, retirement_supported=False, retirement_provider=None),
uninstall_guard_providers=(lambda _session, _module_id: (
ModuleUninstallGuardResult("blocker", "stored_data_present", "Stored files remain."),
),),
)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
maintenance_mode=True,
session=session,
)
self.assertFalse(preflight.allowed)
self.assertIn("stored_data_present", {issue.code for issue in preflight.issues})
self.assertIn("migration_retirement_not_requested", {issue.code for issue in preflight.issues})
def test_module_installer_allows_non_destructive_uninstall_for_migration_owner(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-nondestructive-uninstall-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "uninstall",
"python_package": "govoplan-files",
}])
session.commit()
available = dict(available_module_manifests())
migration = available["files"].migration_spec
self.assertIsNotNone(migration)
assert migration is not None
available["files"] = replace(
available["files"],
migration_spec=replace(migration, retirement_supported=False, retirement_provider=None),
uninstall_guard_providers=(),
)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
maintenance_mode=True,
session=session,
)
self.assertTrue(preflight.allowed)
issues = {issue.code: issue for issue in preflight.issues}
self.assertEqual("warning", issues["migration_retirement_not_requested"].severity)
def test_module_installer_preflight_uses_migration_retirement_provider(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-retirement-provider-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "uninstall",
"python_package": "govoplan-files",
}])
session.commit()
available = dict(available_module_manifests())
migration = available["files"].migration_spec
self.assertIsNotNone(migration)
assert migration is not None
available["files"] = replace(
available["files"],
migration_spec=replace(
migration,
retirement_supported=True,
retirement_provider=lambda _session, _module_id: MigrationRetirementPlan(
supported=True,
summary="Retirement provider can preserve historical migration state.",
warnings=("Operator must verify archived file storage.",),
),
),
)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
maintenance_mode=True,
session=session,
)
codes = {issue.code for issue in preflight.issues}
self.assertTrue(preflight.allowed)
self.assertNotIn("migration_retirement_not_requested", codes)
self.assertIn("migration_retirement_supported", codes)
self.assertIn("migration_retirement_warning", codes)
def test_module_installer_destroy_data_retirement_drops_module_tables(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-destroy-data-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
metadata = MetaData()
table = Table("retirement_example", metadata, Column("id", Integer, primary_key=True))
metadata.create_all(bind=database.engine)
example_model = type("ExampleModel", (), {"__table__": table})
manifest = ModuleManifest(
id="example",
name="Example",
version="0.1.0",
migration_spec=MigrationSpec(
module_id="example",
metadata=metadata,
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(example_model, label="Example"),
),
)
def fake_run(*_args, **_kwargs):
return SimpleNamespace(returncode=0, stdout="", stderr="")
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "example",
"action": "uninstall",
"python_package": "govoplan-example",
"destroy_data": True,
}])
session.commit()
preflight = module_install_preflight(
plan=plan,
available={"example": manifest},
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
maintenance_mode=True,
session=session,
)
self.assertTrue(preflight.allowed)
self.assertIn("migration_retirement_destroy_planned", {issue.code for issue in preflight.issues})
with patch("govoplan_core.core.module_installer.subprocess.run", side_effect=fake_run):
result = run_module_install_plan(
session=session,
plan=plan,
available={"example": manifest},
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url=settings.database_url,
runtime_dir=root / "installer",
)
self.assertEqual("applied", result.status)
self.assertFalse(inspect(database.engine).has_table("retirement_example"))
record = json.loads(result.record_path.read_text(encoding="utf-8"))
self.assertEqual("example", record["retirements"][0]["module_id"])
self.assertIn("database_backup", record["snapshot"])
def test_module_installer_dry_run_writes_run_record_without_applying(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-dry-run-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
}])
session.commit()
result = run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url=settings.database_url,
runtime_dir=root / "installer",
migrate_database=True,
dry_run=True,
)
self.assertEqual("dry-run", result.status)
self.assertTrue(result.record_path.exists())
record_text = result.record_path.read_text(encoding="utf-8")
self.assertIn("pip", record_text)
self.assertIn("database_backup", record_text)
self.assertIn("govoplan_core.commands.module_verify", record_text)
self.assertIn("govoplan_core.commands.init_db", record_text)
runs = list_module_installer_runs(runtime_dir=root / "installer")
self.assertEqual((result.run_id,), tuple(item["run_id"] for item in runs))
self.assertFalse(module_installer_lock_status(runtime_dir=root / "installer")["locked"])
record = read_module_installer_run(runtime_dir=root / "installer", run_id=result.run_id)
self.assertEqual(result.run_id, record["run_id"])
def test_supervised_module_install_rollback_restores_desired_modules(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-desired-rollback-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
def fake_run(*_args, **kwargs):
if kwargs.get("shell"):
return SimpleNamespace(returncode=1, stdout="", stderr="restart failed")
return SimpleNamespace(returncode=0, stdout="", stderr="")
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "example",
"action": "install",
"python_package": "govoplan-example",
"python_ref": "govoplan-example==0.1.4",
}])
save_desired_enabled_modules(session, ("tenancy", "access"))
session.commit()
with patch("govoplan_core.core.module_installer.subprocess.run", side_effect=fake_run):
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url=settings.database_url,
runtime_dir=root / "installer",
restart_command="restart-fails",
)
restored_desired = saved_desired_enabled_modules(session, ("tenancy", "access"))
restored_plan = saved_module_install_plan(session)
self.assertEqual("rolled-back", result.status)
self.assertEqual(("tenancy", "access"), restored_desired)
self.assertEqual(("planned",), tuple(item.status for item in restored_plan.items))
def test_module_installer_external_database_backup_command_is_recorded(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-external-backup-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
backup_script = (
"import json, os, pathlib; "
"pathlib.Path(os.environ['GOVOPLAN_DATABASE_BACKUP_PATH']).write_text('backup', encoding='utf-8'); "
"pathlib.Path(os.environ['GOVOPLAN_DATABASE_BACKUP_METADATA']).write_text(json.dumps({'snapshot': 'external'}), encoding='utf-8')"
)
backup_command = f"{sys.executable} -c {shlex.quote(backup_script)}"
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
}])
session.commit()
result = run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url="postgresql://db.example.invalid/govoplan",
runtime_dir=root / "installer",
migrate_database=True,
database_backup_command=backup_command,
database_restore_command="restore-database",
dry_run=True,
)
record = json.loads(result.record_path.read_text(encoding="utf-8"))
database_backup = record["snapshot"]["database_backup"]
self.assertEqual("external", database_backup["type"])
self.assertEqual({"snapshot": "external"}, database_backup["metadata"])
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
def test_module_installer_external_database_restore_check_runs_before_migrations(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-restore-check-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
backup_script = (
"import os, pathlib; "
"pathlib.Path(os.environ['GOVOPLAN_DATABASE_BACKUP_PATH']).write_text('backup', encoding='utf-8')"
)
check_script = (
"import os, pathlib; "
"pathlib.Path(os.environ['GOVOPLAN_INSTALLER_RUN_DIR'], 'restore-check.marker').write_text("
"pathlib.Path(os.environ['GOVOPLAN_DATABASE_BACKUP_PATH']).read_text(encoding='utf-8'), encoding='utf-8')"
)
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
}])
session.commit()
result = run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url="postgresql://db.example.invalid/govoplan",
runtime_dir=root / "installer",
migrate_database=True,
database_backup_command=f"{sys.executable} -c {shlex.quote(backup_script)}",
database_restore_command="restore-database",
database_restore_check_command=f"{sys.executable} -c {shlex.quote(check_script)}",
dry_run=True,
)
record = json.loads(result.record_path.read_text(encoding="utf-8"))
database_backup = record["snapshot"]["database_backup"]
self.assertEqual(0, database_backup["restore_check"]["return_code"])
self.assertEqual("backup", (result.record_path.parent / "restore-check.marker").read_text(encoding="utf-8"))
def test_module_installer_external_database_restore_command_runs_on_rollback(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-external-restore-", dir=_TEST_ROOT))
runtime_dir = root / "installer"
run_id = "external-restore-test"
run_dir = runtime_dir / "runs" / run_id
run_dir.mkdir(parents=True)
restore_script = (
"import os, pathlib; "
"pathlib.Path(os.environ['GOVOPLAN_INSTALLER_RUN_DIR'], 'restore.marker').write_text("
"os.environ.get('GOVOPLAN_DATABASE_URL', ''), encoding='utf-8')"
)
restore_command = f"{sys.executable} -c {shlex.quote(restore_script)}"
(run_dir / "database.external.backup").write_text("backup", encoding="utf-8")
(run_dir / "record.json").write_text(json.dumps({
"run_id": run_id,
"plan": [],
"snapshot": {
"database_backup": {
"type": "external",
"database_url": "postgresql://db.example.invalid/govoplan",
"backup_path": "database.external.backup",
"metadata_path": "database-backup-metadata.json",
"restore_command": restore_command,
},
},
}), encoding="utf-8")
result = rollback_module_install_run(run_id=run_id, runtime_dir=runtime_dir, npm_bin="npm")
self.assertEqual("rolled-back", result.status)
self.assertEqual("postgresql://db.example.invalid/govoplan", (run_dir / "restore.marker").read_text(encoding="utf-8"))
record = json.loads((run_dir / "record.json").read_text(encoding="utf-8"))
self.assertTrue(record["rollback_database_restore"]["restored"])
def test_module_installer_request_queue_round_trips(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-request-queue-", dir=_TEST_ROOT))
runtime_dir = root / "installer"
queued = queue_module_installer_request(
runtime_dir=runtime_dir,
requested_by="user-1",
options={"migrate_database": True, "health_urls": ["http://127.0.0.1:8000/health"]},
)
request_id = str(queued["request_id"])
self.assertEqual("queued", queued["status"])
self.assertEqual((request_id,), tuple(item["request_id"] for item in list_module_installer_requests(runtime_dir=runtime_dir)))
claimed = claim_next_module_installer_request(runtime_dir=runtime_dir)
self.assertIsNotNone(claimed)
assert claimed is not None
self.assertEqual(request_id, claimed["request_id"])
self.assertEqual("running", claimed["status"])
updated = update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={"status": "completed", "result": {"return_code": 0}},
)
self.assertEqual("completed", updated["status"])
self.assertEqual("completed", read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)["status"])
cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1")
cancelled = cancel_module_installer_request(
runtime_dir=runtime_dir,
request_id=str(cancellable["request_id"]),
cancelled_by="user-2",
)
self.assertEqual("cancelled", cancelled["status"])
self.assertEqual("user-2", cancelled["cancelled_by"])
retry = retry_module_installer_request(
runtime_dir=runtime_dir,
request_id=str(cancellable["request_id"]),
requested_by="user-3",
)
self.assertEqual("queued", retry["status"])
self.assertEqual(cancellable["request_id"], retry["retry_of"])
def test_module_installer_daemon_status_reports_heartbeat(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-daemon-status-", dir=_TEST_ROOT))
runtime_dir = root / "installer"
initial = module_installer_daemon_status(runtime_dir=runtime_dir)
self.assertFalse(initial["running"])
status = update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={"status": "polling", "current_request_id": None},
)
self.assertTrue(status["running"])
self.assertEqual("polling", status["status"])
def test_module_package_catalog_loads_json_entries(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({
"modules": [{
"module_id": "files",
"name": "Files",
"version": "0.1.4",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
"tags": ["official"],
}],
}), encoding="utf-8")
catalog = module_package_catalog(catalog_path)
self.assertEqual("files", catalog[0]["module_id"])
self.assertEqual("install", catalog[0]["action"])
self.assertEqual(["official"], catalog[0]["tags"])
validation = validate_module_package_catalog(catalog_path)
self.assertTrue(validation["valid"])
self.assertEqual("files", validation["modules"][0]["module_id"])
def test_module_package_catalog_validation_reports_bad_entries(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({"modules": [{"name": "Missing id"}]}), encoding="utf-8")
validation = validate_module_package_catalog(catalog_path)
self.assertFalse(validation["valid"])
self.assertIn("module_id", str(validation["error"]))
def test_module_package_catalog_validates_signed_approved_channel(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-signed-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
private_key_path = root / "catalog-private.pem"
signed_path = root / "catalog.signed.json"
private_key = Ed25519PrivateKey.generate()
private_key_path.write_bytes(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
))
public_key = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
catalog_path.write_text(json.dumps({
"channel": "stable",
"modules": [{
"module_id": "files",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
}],
}), encoding="utf-8")
sign_module_package_catalog(
path=catalog_path,
key_id="release-1",
private_key_path=private_key_path,
output_path=signed_path,
)
validation = validate_module_package_catalog(
signed_path,
require_trusted=True,
approved_channels=("stable",),
trusted_keys={"release-1": base64.b64encode(public_key).decode("ascii")},
)
self.assertTrue(validation["valid"], validation["error"])
self.assertTrue(validation["signed"])
self.assertTrue(validation["trusted"])
self.assertEqual("stable", validation["channel"])
def test_module_package_catalog_rejects_unapproved_channel_when_required(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-channel-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({"channel": "nightly", "modules": [{"module_id": "files"}]}), encoding="utf-8")
validation = validate_module_package_catalog(catalog_path, approved_channels=("stable",))
self.assertFalse(validation["valid"])
self.assertIn("not approved", str(validation["error"]))
def test_module_package_catalog_rejects_expired_catalog(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-expired-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({
"channel": "stable",
"sequence": 1,
"generated_at": "2000-01-01T00:00:00Z",
"expires_at": "2000-01-02T00:00:00Z",
"modules": [{"module_id": "files"}],
}), encoding="utf-8")
validation = validate_module_package_catalog(catalog_path)
self.assertFalse(validation["valid"])
self.assertIn("expired", str(validation["error"]))
def test_module_package_catalog_records_sequence_acceptance(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-sequence-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
state_path = root / "sequence-state.json"
catalog_path.write_text(json.dumps({
"channel": "stable",
"sequence": 7,
"generated_at": "2026-07-07T00:00:00Z",
"expires_at": "2030-12-31T23:59:59Z",
"modules": [{"module_id": "files"}],
}), encoding="utf-8")
with patch.dict(os.environ, {
"GOVOPLAN_MODULE_PACKAGE_CATALOG_SEQUENCE_STATE": str(state_path),
"GOVOPLAN_MODULE_PACKAGE_CATALOG_ENFORCE_SEQUENCE": "true",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS": "",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "",
}):
validation = validate_module_package_catalog(catalog_path)
self.assertTrue(validation["valid"], validation["error"])
record_module_package_catalog_acceptance(validation)
replay = validate_module_package_catalog(catalog_path)
self.assertFalse(replay["valid"])
self.assertIn("already accepted", str(replay["error"]))
def test_module_license_decision_is_observe_only_unless_enforced(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-license-observe-", dir=_TEST_ROOT))
missing_license = root / "missing-license.json"
with patch.dict(os.environ, {
"GOVOPLAN_LICENSE_FILE": str(missing_license),
"GOVOPLAN_LICENSE_ENFORCEMENT": "",
}):
observe = module_license_decision(("module.mail",))
with patch.dict(os.environ, {
"GOVOPLAN_LICENSE_FILE": str(missing_license),
"GOVOPLAN_LICENSE_ENFORCEMENT": "true",
}):
enforced = module_license_decision(("module.mail",))
self.assertTrue(observe["allowed"])
self.assertFalse(observe["enforced"])
self.assertFalse(enforced["allowed"])
self.assertTrue(enforced["enforced"])
def test_module_license_validates_signed_feature_license(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-license-signed-", dir=_TEST_ROOT))
license_path = root / "license.json"
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
payload = {
"license_id": "test-license",
"subject": "Test installation",
"features": ["module.mail"],
"valid_from": "2026-01-01T00:00:00Z",
"valid_until": "2030-12-31T23:59:59Z",
}
signature = private_key.sign(json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
payload["signature"] = {
"algorithm": "ed25519",
"key_id": "license-1",
"value": base64.b64encode(signature).decode("ascii"),
}
license_path.write_text(json.dumps(payload), encoding="utf-8")
trusted_keys = {"license-1": base64.b64encode(public_key).decode("ascii")}
validation = validate_module_license(license_path, trusted_keys=trusted_keys, require_trusted=True)
self.assertTrue(validation["valid"], validation["error"])
self.assertTrue(validation["trusted"])
self.assertEqual(["module.mail"], validation["features"])
def test_module_installer_validates_package_catalog_from_cli(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-cli-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({"modules": [{"module_id": "files"}]}), encoding="utf-8")
result = subprocess.run(
[
sys.executable,
"-m",
"govoplan_core.commands.module_installer",
"--validate-package-catalog",
str(catalog_path),
"--format",
"json",
],
cwd=str(Path(__file__).resolve().parents[1]),
env=os.environ.copy(),
text=True,
capture_output=True,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(json.loads(result.stdout)["valid"])
def test_module_installer_daemon_once_exits_with_empty_queue(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-daemon-once-", dir=_TEST_ROOT))
result = subprocess.run(
[
sys.executable,
"-m",
"govoplan_core.commands.module_installer",
"--daemon-once",
"--runtime-dir",
str(root / "installer"),
"--database-url",
f"sqlite:///{root / 'daemon.db'}",
],
cwd=str(Path(__file__).resolve().parents[1]),
env=os.environ.copy(),
text=True,
capture_output=True,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
def test_module_installer_rollback_rebuilds_webui_when_original_run_built(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-rollback-webui-", dir=_TEST_ROOT))
runtime_dir = root / "installer"
run_id = "rollback-webui-test"
run_dir = runtime_dir / "runs" / run_id
webui_root = root / "webui"
run_dir.mkdir(parents=True)
webui_root.mkdir()
(webui_root / "package.json").write_text('{"scripts":{"build":"vite build"}}', encoding="utf-8")
(run_dir / "package.json.before").write_text('{"scripts":{"build":"vite build"},"dependencies":{}}', encoding="utf-8")
(run_dir / "package-lock.json.before").write_text('{"lockfileVersion":3}', encoding="utf-8")
(run_dir / "record.json").write_text(json.dumps({
"run_id": run_id,
"build_webui": True,
"plan": [{
"module_id": "example",
"action": "install",
"python_package": "govoplan-example",
}],
"snapshot": {
"webui_root": str(webui_root),
"package_json": "package.json.before",
"package_lock": "package-lock.json.before",
},
}), encoding="utf-8")
with patch("govoplan_core.core.module_installer.subprocess.run") as run_mock:
run_mock.return_value = SimpleNamespace(returncode=0, stdout="", stderr="")
result = rollback_module_install_run(run_id=run_id, runtime_dir=runtime_dir, npm_bin="npm")
commands = [call.args[0] for call in run_mock.call_args_list]
self.assertEqual("rolled-back", result.status)
self.assertIn([sys.executable, "-m", "pip", "uninstall", "-y", "govoplan-example"], commands)
self.assertIn(["npm", "install"], commands)
self.assertIn(["npm", "run", "build"], commands)
def test_create_app_uses_saved_desired_module_state_on_startup(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-state-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
session.add(SystemSettings(
id="global",
settings={"module_management": {"desired_enabled": ["files"]}},
))
session.commit()
config = GovoplanServerConfig(
title="GovOPlaN Module State Test",
version="test",
settings=settings,
enabled_modules=(),
base_routers=(),
post_module_routers=(),
extra_routers=(),
lifespan=None,
app_configurators=(),
)
app = create_app(config)
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({"tenancy", "access", "files"}, payload_modules)
def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-state-stale-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
session.add(SystemSettings(
id="global",
settings={"module_management": {"desired_enabled": ["files", "missing-module"]}},
))
session.commit()
config = GovoplanServerConfig(
title="GovOPlaN Module State Test",
version="test",
settings=settings,
enabled_modules=(),
base_routers=(),
post_module_routers=(),
extra_routers=(),
lifespan=None,
app_configurators=(),
)
app = create_app(config)
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({"tenancy", "access", "files"}, payload_modules)
def test_create_app_preserves_admin_when_configured_and_saved_state_is_older(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-state-admin-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
with database.session() as session:
session.add(SystemSettings(
id="global",
settings={"module_management": {"desired_enabled": ["files"]}},
))
session.commit()
config = GovoplanServerConfig(
title="GovOPlaN Module State Test",
version="test",
settings=settings,
enabled_modules=("admin",),
base_routers=(),
post_module_routers=(),
extra_routers=(),
lifespan=None,
app_configurators=(),
)
app = create_app(config)
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({"tenancy", "access", "admin", "files"}, payload_modules)
def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None:
app, _settings_obj = self._app_for_modules(())
lifecycle = getattr(app.state, "govoplan_lifecycle", None)
self.assertIsNotNone(lifecycle)
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"tenancy", "access"}, {item["id"] for item in response.json()["modules"]})
self.assertFalse(any(getattr(route, "path", "") == "/api/v1/files" for route in app.routes))
result = lifecycle.apply_enabled_modules(("files",), migrate=False)
self.assertEqual(("files",), result.activated_modules)
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"tenancy", "access", "files"}, {item["id"] for item in response.json()["modules"]})
self.assertTrue(any(getattr(route, "path", "") == "/api/v1/files" for route in app.routes))
self.assertEqual(401, client.get("/api/v1/files").status_code)
result = lifecycle.apply_enabled_modules((), migrate=False)
self.assertEqual(("files",), result.deactivated_modules)
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"tenancy", "access"}, {item["id"] for item in response.json()["modules"]})
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_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
cases = (
((), ("govoplan_files", "govoplan_mail", "govoplan_campaign")),
(("files",), ("govoplan_mail", "govoplan_campaign")),
(("mail",), ("govoplan_files", "govoplan_campaign")),
(("campaigns",), ("govoplan_files", "govoplan_mail")),
(("campaigns", "files"), ("govoplan_mail",)),
(("campaigns", "mail"), ("govoplan_files",)),
(("campaigns", "files", "mail"), ()),
)
for enabled_modules, blocked_modules in cases:
with self.subTest(enabled_modules=enabled_modules, blocked_modules=blocked_modules):
self._run_physical_absence_probe(enabled_modules=enabled_modules, blocked_modules=blocked_modules)
def test_module_route_factories_receive_runtime_settings(self) -> None:
app, settings = self._app_for_modules(("files", "mail"))
self.assertIsNotNone(app)
from govoplan_files.backend.runtime import get_settings as get_files_settings
from govoplan_mail.backend.runtime import get_settings as get_mail_settings
self.assertIs(settings, get_files_settings())
self.assertIs(settings, get_mail_settings())
def test_module_migrations_are_registered_only_for_enabled_modules(self) -> None:
core_plan = migration_metadata_plan(build_platform_registry(()))
self.assertEqual((), core_plan.script_locations)
self.assertEqual(1, len(core_plan.metadata))
files_plan = migration_metadata_plan(build_platform_registry(("files",)))
self.assertEqual(1, len(files_plan.script_locations))
self.assertTrue(files_plan.script_locations[0].endswith("govoplan_files/backend/migrations/versions"))
full_plan = migration_metadata_plan(build_platform_registry(("campaigns", "files", "mail")))
locations = "\n".join(full_plan.script_locations)
self.assertIn("govoplan_campaign/backend/migrations/versions", locations)
self.assertIn("govoplan_files/backend/migrations/versions", locations)
self.assertIn("govoplan_mail/backend/migrations/versions", locations)
def test_local_storage_backend_reads_from_fallback_roots_without_copying(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-storage-fallback-") as directory:
root = Path(directory) / "primary"
fallback = Path(directory) / "fallback"
key = "tenant-a/files/demo.txt"
(fallback / key).parent.mkdir(parents=True)
(fallback / key).write_bytes(b"fallback-data")
backend = LocalFilesystemStorageBackend(root=root, fallback_roots=(fallback,))
self.assertTrue(backend.exists(key))
self.assertEqual(b"fallback-data", backend.get_bytes(key))
self.assertEqual([b"fallback", b"-data"], list(backend.iter_bytes(key, chunk_size=8)))
self.assertFalse((root / key).exists())
backend.delete(key)
self.assertTrue((fallback / key).exists())
self.assertEqual(b"fallback-data", backend.get_bytes(key))
with self.assertRaises(StorageBackendError):
backend.get_bytes("../escape.txt")
if __name__ == "__main__":
unittest.main()