1427 lines
65 KiB
Python
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()
|