All checks were successful
Dependency Audit / dependency-audit (push) Successful in 1m35s
3847 lines
169 KiB
Python
3847 lines
169 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import textwrap
|
|
import tomllib
|
|
import unittest
|
|
import urllib.error
|
|
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 import APIRouter
|
|
from fastapi.testclient import TestClient
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
|
from govoplan_core.core.events import event_context
|
|
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_install_catalog_companion_module_ids,
|
|
module_manifest_compatibility_issues,
|
|
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,
|
|
structured_install_commands,
|
|
supervise_module_install_plan,
|
|
update_module_installer_daemon_status,
|
|
update_module_installer_request,
|
|
)
|
|
from govoplan_core.core.configuration_packages import (
|
|
CONFIGURATION_PROVIDER_CAPABILITY,
|
|
ConfigurationApplyResult,
|
|
ConfigurationExportResult,
|
|
ConfigurationExportSelection,
|
|
ConfigurationPackageFragment,
|
|
ConfigurationPlanItem,
|
|
ConfigurationPreflightContext,
|
|
ConfigurationPreflightResult,
|
|
ConfigurationProvider,
|
|
ConfigurationProviderDescription,
|
|
ConfigurationRequiredData,
|
|
apply_configuration_package,
|
|
configuration_package_catalog,
|
|
dry_run_configuration_package,
|
|
export_configuration_package,
|
|
sign_configuration_package_catalog,
|
|
validate_configuration_package_catalog,
|
|
)
|
|
from govoplan_core.core.module_license import issue_module_license, module_license_decision, module_license_diagnostics, 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 FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult
|
|
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
|
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest
|
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
|
from govoplan_core.admin.models import SystemSettings
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_core.db.bootstrap import bootstrap_dev_data
|
|
from govoplan_core.db.migrations import run_registered_module_migration_tasks
|
|
from govoplan_core.db.session import configure_database as configure_database_handle, get_database, reset_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_core.server.route_validation import RouteCollisionError
|
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
|
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",
|
|
"dashboard": "govoplan_dashboard",
|
|
"files": "govoplan_files",
|
|
"mail": "govoplan_mail",
|
|
"campaigns": "govoplan_campaign",
|
|
"docs": "govoplan_docs",
|
|
"ops": "govoplan_ops",
|
|
}
|
|
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
_PACKAGE_NAME_RE = re.compile(r"^@govoplan/[a-z0-9_-]+-webui$")
|
|
|
|
|
|
def configure_database(database_url: str):
|
|
return configure_database_handle(database_url, dispose_previous=True)
|
|
|
|
|
|
def _join_route_path(prefix: str, path: str) -> str:
|
|
if not prefix:
|
|
return path
|
|
if not path:
|
|
return prefix
|
|
return f"{prefix.rstrip('/')}/{path.lstrip('/')}"
|
|
|
|
|
|
def _route_paths(routes_or_app, *, prefix: str = "") -> set[str]:
|
|
routes = getattr(routes_or_app, "routes", routes_or_app)
|
|
paths: set[str] = set()
|
|
for route in routes:
|
|
path = getattr(route, "path", None)
|
|
if path:
|
|
paths.add(_join_route_path(prefix, path))
|
|
include_context = getattr(route, "include_context", None)
|
|
nested_prefix = _join_route_path(prefix, str(getattr(include_context, "prefix", "") or ""))
|
|
nested_router = getattr(route, "original_router", None)
|
|
if nested_router is not None:
|
|
paths.update(_route_paths(nested_router, prefix=nested_prefix))
|
|
nested_routes = getattr(route, "routes", None)
|
|
if nested_routes is not None:
|
|
paths.update(_route_paths(nested_routes, prefix=nested_prefix))
|
|
return paths
|
|
|
|
|
|
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:
|
|
reset_database(dispose=True)
|
|
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", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
|
|
self.assertEqual(manifests["campaigns"].dependencies, ())
|
|
self.assertTrue(manifests["campaigns"].required_capabilities)
|
|
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
|
|
self.assertEqual(manifests["dashboard"].dependencies, ())
|
|
self.assertTrue(manifests["dashboard"].required_capabilities)
|
|
self.assertEqual(manifests["docs"].dependencies, ())
|
|
self.assertTrue(manifests["docs"].required_capabilities)
|
|
self.assertEqual(manifests["ops"].dependencies, ())
|
|
self.assertTrue(manifests["ops"].required_capabilities)
|
|
|
|
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_obsolete_access_core_import_aliases_are_removed(self) -> None:
|
|
obsolete_paths = (
|
|
"govoplan_core.security.api_keys",
|
|
"govoplan_core.security.sessions",
|
|
"govoplan_core.security.passwords",
|
|
"govoplan_core.api.v1.auth",
|
|
"govoplan_core.api.v1.admin",
|
|
"govoplan_core.api.v1.admin_schemas",
|
|
"govoplan_core.admin.service",
|
|
"govoplan_core.admin.governance",
|
|
)
|
|
|
|
for legacy_path in obsolete_paths:
|
|
with self.subTest(legacy_path=legacy_path):
|
|
self.assertIsNone(importlib.util.find_spec(legacy_path))
|
|
|
|
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"))
|
|
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
|
|
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
|
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
|
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
|
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
|
|
|
|
def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
|
|
webui_src = Path(__file__).resolve().parents[1] / "webui" / "src"
|
|
self.assertFalse((webui_src / "api" / "admin.ts").exists())
|
|
for path in webui_src.rglob("*.ts*"):
|
|
with self.subTest(path=path.relative_to(webui_src)):
|
|
self.assertNotIn("api/admin", path.read_text(encoding="utf-8"))
|
|
|
|
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_core.tenancy.scope import scope_registry
|
|
from govoplan_tenancy.backend.db.models import Tenant
|
|
|
|
self.assertIs(AccessBase, Base)
|
|
self.assertEqual("access_accounts", Account.__tablename__)
|
|
self.assertEqual("core_scopes", Tenant.__tablename__)
|
|
self.assertEqual("access_users", User.__tablename__)
|
|
self.assertEqual("access_api_keys", ApiKey.__tablename__)
|
|
self.assertEqual("access_auth_sessions", AuthSession.__tablename__)
|
|
self.assertEqual("audit_log", AuditLog.__tablename__)
|
|
self.assertEqual("admin_governance_templates", GovernanceTemplate.__tablename__)
|
|
self.assertEqual("core_system_settings", SystemSettings.__tablename__)
|
|
self.assertIn("access_accounts", Base.metadata.tables)
|
|
self.assertIn("core_scopes", scope_registry.metadata.tables)
|
|
self.assertNotIn("core_scopes", Base.metadata.tables)
|
|
self.assertNotIn("accounts", Base.metadata.tables)
|
|
self.assertNotIn("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_available_module_manifests_follow_contract_shape(self) -> None:
|
|
manifests = available_module_manifests()
|
|
for manifest_id, manifest in manifests.items():
|
|
with self.subTest(manifest_id=manifest_id):
|
|
self.assertRegex(manifest.id, _MODULE_ID_RE)
|
|
self.assertEqual(manifest_id, manifest.id)
|
|
self.assertTrue(manifest.name.strip())
|
|
self.assertTrue(manifest.version.strip())
|
|
self.assertEqual(len(manifest.dependencies), len(set(manifest.dependencies)))
|
|
self.assertEqual(len(manifest.optional_dependencies), len(set(manifest.optional_dependencies)))
|
|
self.assertFalse(set(manifest.dependencies) & set(manifest.optional_dependencies))
|
|
self.assertNotIn(manifest.id, manifest.dependencies)
|
|
self.assertNotIn(manifest.id, manifest.optional_dependencies)
|
|
self.assertEqual(manifest.compatibility.manifest_contract_version, "1")
|
|
if manifest.migration_spec is not None:
|
|
self.assertEqual(manifest.id, manifest.migration_spec.module_id)
|
|
self.assertTrue(manifest.migration_spec.metadata is not None or manifest.migration_spec.script_location)
|
|
if manifest.frontend is not None:
|
|
self.assertEqual(manifest.id, manifest.frontend.module_id)
|
|
if manifest.frontend.package_name is not None:
|
|
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
|
|
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes):
|
|
self.assertTrue(route.path.startswith("/"))
|
|
self.assertTrue(route.component.strip())
|
|
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
|
|
self.assertTrue(item.path.startswith("/"))
|
|
self.assertTrue(item.label.strip())
|
|
if item.icon is not None:
|
|
self.assertTrue(item.icon.strip())
|
|
|
|
def test_registry_rejects_unsupported_manifest_contract_version(self) -> None:
|
|
registry = PlatformRegistry()
|
|
registry.register(ModuleManifest(
|
|
id="example",
|
|
name="Example",
|
|
version="test",
|
|
compatibility=ModuleCompatibility(manifest_contract_version="2"),
|
|
))
|
|
|
|
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
|
|
registry.validate()
|
|
|
|
def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
|
|
registry = PlatformRegistry()
|
|
registry.register(ModuleManifest(
|
|
id="example",
|
|
name="Example",
|
|
version="test",
|
|
frontend=FrontendModule(
|
|
module_id="example",
|
|
package_name="@govoplan/example-webui",
|
|
routes=(FrontendRoute(path="missing-leading-slash", component="ExamplePage"),),
|
|
),
|
|
))
|
|
|
|
with self.assertRaisesRegex(RegistryError, "Frontend route"):
|
|
registry.validate()
|
|
|
|
def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
|
|
first = APIRouter()
|
|
second = APIRouter()
|
|
|
|
@first.get("/collision")
|
|
def first_collision() -> dict[str, bool]:
|
|
return {"first": True}
|
|
|
|
@second.get("/collision")
|
|
def second_collision() -> dict[str, bool]:
|
|
return {"second": True}
|
|
|
|
config = GovoplanServerConfig(
|
|
title="GovOPlaN Route Collision Test",
|
|
version="test",
|
|
settings=_settings(Path(tempfile.mkdtemp(prefix="govoplan-route-collision-", dir=_TEST_ROOT))),
|
|
enabled_modules=(),
|
|
base_routers=(first, second),
|
|
post_module_routers=(),
|
|
extra_routers=(),
|
|
lifespan=None,
|
|
app_configurators=(),
|
|
)
|
|
|
|
with self.assertRaisesRegex(RouteCollisionError, "GET .*/collision"):
|
|
create_app(config)
|
|
|
|
def test_module_lifecycle_rejects_duplicate_module_routes(self) -> None:
|
|
def route_factory(label: str):
|
|
def factory(_context):
|
|
router = APIRouter()
|
|
|
|
@router.get("/collision")
|
|
def collision() -> dict[str, str]:
|
|
return {"module": label}
|
|
|
|
return router
|
|
|
|
return factory
|
|
|
|
config = GovoplanServerConfig(
|
|
title="GovOPlaN Module Route Collision Test",
|
|
version="test",
|
|
settings=_settings(Path(tempfile.mkdtemp(prefix="govoplan-module-route-collision-", dir=_TEST_ROOT))),
|
|
enabled_modules=("alpha", "beta"),
|
|
base_routers=(),
|
|
post_module_routers=(),
|
|
extra_routers=(),
|
|
lifespan=None,
|
|
app_configurators=(),
|
|
manifest_factories=(
|
|
lambda: ModuleManifest(id="alpha", name="Alpha", version="test", route_factory=route_factory("alpha")),
|
|
lambda: ModuleManifest(id="beta", name="Beta", version="test", route_factory=route_factory("beta")),
|
|
),
|
|
)
|
|
|
|
with self.assertRaisesRegex(RouteCollisionError, "GET .*/collision"):
|
|
create_app(config)
|
|
|
|
def test_enabled_module_permutations_register_expected_routes(self) -> None:
|
|
cases = (
|
|
("core_only", (), {"access"}, set()),
|
|
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
|
|
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
|
|
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
|
|
("campaign_without_files", ("campaigns", "mail"), {"access", "campaigns", "mail"}, {"/api/v1/campaigns", "/api/v1/mail"}),
|
|
("campaign_with_files_but_no_mail", ("campaigns", "files"), {"access", "campaigns", "files"}, {"/api/v1/campaigns", "/api/v1/files"}),
|
|
("dashboard_only", ("dashboard",), {"access", "dashboard"}, set()),
|
|
("docs_and_ops", ("docs", "ops"), {"access", "docs", "ops"}, {"/api/v1/docs", "/api/v1/ops"}),
|
|
("full_product", ("dashboard", "campaigns", "files", "mail", "docs", "ops"), {"access", "dashboard", "campaigns", "files", "mail", "docs", "ops"}, {"/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"}),
|
|
)
|
|
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 = _route_paths(app)
|
|
self.assertIn("/api/v1/platform/modules", route_paths)
|
|
self.assertIn("/api/v1/auth/login", route_paths)
|
|
self.assertIn("/api/v1/admin/users", route_paths)
|
|
for prefix in ("/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"):
|
|
has_prefix = any(path == prefix or path.startswith(f"{prefix}/") for path in route_paths)
|
|
self.assertEqual(prefix in expected_prefixes, has_prefix, f"{name}: {prefix}")
|
|
|
|
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 = _route_paths(access_only_app)
|
|
self.assertIn("/api/v1/admin/users", access_only_paths)
|
|
self.assertNotIn("/api/v1/admin/system/governance-templates", access_only_paths)
|
|
|
|
admin_app, _settings_obj = self._app_for_modules(("admin",))
|
|
admin_paths = _route_paths(admin_app)
|
|
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
|
|
from govoplan_core.db.session import reset_database
|
|
|
|
try:
|
|
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))
|
|
finally:
|
|
reset_database(dispose=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_tenant_lifecycle_with_product_modules_installed_and_absent(self) -> None:
|
|
cases = (
|
|
("tenancy_only", ()),
|
|
("files_only", ("files",)),
|
|
("mail_only", ("mail",)),
|
|
("campaign_only", ("campaigns",)),
|
|
("full_product", ("files", "mail", "campaigns")),
|
|
)
|
|
for name, product_modules in cases:
|
|
with self.subTest(name=name):
|
|
app, _settings_obj = self._app_for_modules(("tenancy", *product_modules))
|
|
database = get_database()
|
|
create_scope_tables(database.engine)
|
|
Base.metadata.create_all(bind=database.engine)
|
|
with database.session() as session:
|
|
bootstrap_dev_data(session, user_password="test-admin")
|
|
|
|
with TestClient(app) as client:
|
|
login = client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": "admin@example.local", "password": "test-admin"},
|
|
)
|
|
self.assertEqual(200, login.status_code, login.text)
|
|
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
|
|
|
tenancy_switch = client.post(
|
|
"/api/v1/tenancy/switch-tenant",
|
|
headers=headers,
|
|
json={"tenant_id": login.json()["tenant"]["id"]},
|
|
)
|
|
self.assertEqual(200, tenancy_switch.status_code, tenancy_switch.text)
|
|
self.assertEqual(login.json()["tenant"]["id"], tenancy_switch.json()["tenant_id"])
|
|
|
|
created = client.post(
|
|
"/api/v1/admin/tenants",
|
|
headers=headers,
|
|
json={
|
|
"slug": f"lifecycle-{name}",
|
|
"name": f"Lifecycle {name}",
|
|
"settings": {},
|
|
},
|
|
)
|
|
self.assertEqual(201, created.status_code, created.text)
|
|
tenant_id = created.json()["id"]
|
|
|
|
updated = client.patch(
|
|
f"/api/v1/admin/tenants/{tenant_id}",
|
|
headers=headers,
|
|
json={"name": f"Lifecycle {name} Updated"},
|
|
)
|
|
self.assertEqual(200, updated.status_code, updated.text)
|
|
self.assertEqual(f"Lifecycle {name} Updated", updated.json()["name"])
|
|
|
|
current_tenant_retire = client.request(
|
|
"DELETE",
|
|
f"/api/v1/admin/tenants/{login.json()['tenant']['id']}",
|
|
headers=headers,
|
|
json={"mode": "retire", "reason": "active tenant guard"},
|
|
)
|
|
self.assertEqual(409, current_tenant_retire.status_code, current_tenant_retire.text)
|
|
|
|
plan = client.get(
|
|
f"/api/v1/admin/tenants/{tenant_id}/deletion-plan",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(200, plan.status_code, plan.text)
|
|
self.assertTrue(plan.json()["allowed"])
|
|
self.assertFalse(plan.json()["destructive_supported"])
|
|
|
|
destructive = client.request(
|
|
"DELETE",
|
|
f"/api/v1/admin/tenants/{tenant_id}",
|
|
headers=headers,
|
|
json={"mode": "destroy", "reason": "not supported"},
|
|
)
|
|
self.assertEqual(409, destructive.status_code, destructive.text)
|
|
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]}
|
|
self.assertIn("tenant_data_present", issue_codes)
|
|
|
|
with database.session() as session:
|
|
empty_tenant = Tenant(
|
|
slug=f"empty-destroy-{name}",
|
|
name=f"Empty Destroy {name}",
|
|
settings={},
|
|
is_active=True,
|
|
)
|
|
session.add(empty_tenant)
|
|
session.commit()
|
|
empty_tenant_id = empty_tenant.id
|
|
|
|
destroyed = client.request(
|
|
"DELETE",
|
|
f"/api/v1/admin/tenants/{empty_tenant_id}",
|
|
headers=headers,
|
|
json={"mode": "destroy", "reason": "empty tenant cleanup"},
|
|
)
|
|
self.assertEqual(200, destroyed.status_code, destroyed.text)
|
|
self.assertEqual("destroy", destroyed.json()["plan"]["action"])
|
|
self.assertTrue(destroyed.json()["plan"]["destructive_supported"])
|
|
|
|
retired = client.request(
|
|
"DELETE",
|
|
f"/api/v1/admin/tenants/{tenant_id}",
|
|
headers=headers,
|
|
json={"mode": "retire", "reason": "module permutation test"},
|
|
)
|
|
self.assertEqual(200, retired.status_code, retired.text)
|
|
retired_item = retired.json()["item"]
|
|
self.assertFalse(retired_item["is_active"])
|
|
self.assertEqual("retired", retired_item["settings"]["lifecycle"]["state"])
|
|
self.assertEqual("module permutation test", retired_item["settings"]["lifecycle"]["reason"])
|
|
|
|
def test_registry_rejects_missing_required_capability(self) -> None:
|
|
registry = PlatformRegistry()
|
|
registry.register(ModuleManifest(
|
|
id="needs_auth",
|
|
name="Needs Auth",
|
|
version="test",
|
|
required_capabilities=("auth.principalResolver",),
|
|
))
|
|
|
|
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
|
|
registry.validate()
|
|
|
|
def test_registry_resolves_required_interface_ranges(self) -> None:
|
|
registry = PlatformRegistry()
|
|
registry.register(ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="1.4.0",
|
|
provides_interfaces=(ModuleInterfaceProvider(name="files.spaces", version="1.4.0"),),
|
|
))
|
|
registry.register(ModuleManifest(
|
|
id="campaigns",
|
|
name="Campaigns",
|
|
version="1.1.0",
|
|
requires_interfaces=(
|
|
ModuleInterfaceRequirement(
|
|
name="files.spaces",
|
|
version_min="1.0.0",
|
|
version_max_exclusive="2.0.0",
|
|
),
|
|
),
|
|
))
|
|
|
|
snapshot = registry.validate()
|
|
|
|
self.assertEqual({"files", "campaigns"}, {manifest.id for manifest in snapshot.manifests})
|
|
|
|
def test_registry_rejects_missing_required_interface(self) -> None:
|
|
registry = PlatformRegistry()
|
|
registry.register(ModuleManifest(
|
|
id="campaigns",
|
|
name="Campaigns",
|
|
version="1.1.0",
|
|
requires_interfaces=(ModuleInterfaceRequirement(name="files.spaces", version_min="1.0.0"),),
|
|
))
|
|
|
|
with self.assertRaisesRegex(RegistryError, "requires unavailable interface"):
|
|
registry.validate()
|
|
|
|
def test_registry_rejects_incompatible_optional_interface_provider(self) -> None:
|
|
registry = PlatformRegistry()
|
|
registry.register(ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="2.0.0",
|
|
provides_interfaces=(ModuleInterfaceProvider(name="files.spaces", version="2.0.0"),),
|
|
))
|
|
registry.register(ModuleManifest(
|
|
id="campaigns",
|
|
name="Campaigns",
|
|
version="1.1.0",
|
|
requires_interfaces=(
|
|
ModuleInterfaceRequirement(
|
|
name="files.spaces",
|
|
version_min="1.0.0",
|
|
version_max_exclusive="2.0.0",
|
|
optional=True,
|
|
),
|
|
),
|
|
))
|
|
|
|
with self.assertRaisesRegex(RegistryError, "available providers"):
|
|
registry.validate()
|
|
|
|
def test_access_and_organizations_do_not_hard_depend_on_tenancy(self) -> None:
|
|
manifests = available_module_manifests()
|
|
|
|
self.assertNotIn("tenancy", manifests["access"].dependencies)
|
|
self.assertIn("tenancy", manifests["access"].optional_dependencies)
|
|
self.assertNotIn("tenancy", manifests["organizations"].dependencies)
|
|
self.assertIn("tenancy", manifests["organizations"].optional_dependencies)
|
|
|
|
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(("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("manual", saved.items[0].source)
|
|
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_preserves_catalog_source(self) -> None:
|
|
item = normalize_module_install_plan_item({
|
|
"module_id": "files",
|
|
"action": "install",
|
|
"source": "catalog",
|
|
"catalog": {
|
|
"channel": "stable",
|
|
"sequence": 8,
|
|
"key_id": "release",
|
|
},
|
|
"python_package": "govoplan-files",
|
|
"python_ref": "govoplan-files==0.1.4",
|
|
})
|
|
|
|
self.assertEqual("catalog", item.source)
|
|
self.assertEqual("catalog", item.as_dict()["source"])
|
|
self.assertEqual("stable", item.catalog["channel"] if item.catalog else None)
|
|
self.assertEqual(8, item.as_dict()["catalog"]["sequence"])
|
|
|
|
def test_module_install_plan_update_uses_package_install_commands(self) -> None:
|
|
item = normalize_module_install_plan_item({
|
|
"module_id": "files",
|
|
"action": "update",
|
|
"python_package": "govoplan-files",
|
|
"python_ref": "govoplan-files==0.2.0",
|
|
"webui_package": "@govoplan/files-webui",
|
|
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0",
|
|
"data_safety_acknowledged": True,
|
|
})
|
|
plan = ModuleInstallPlan(items=(item,))
|
|
|
|
self.assertEqual("update", item.action)
|
|
self.assertTrue(item.data_safety_acknowledged)
|
|
self.assertTrue(item.as_dict()["data_safety_acknowledged"])
|
|
self.assertEqual((
|
|
"python -m pip install govoplan-files==0.2.0",
|
|
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0' && npm install",
|
|
), module_install_plan_commands(plan))
|
|
|
|
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
|
|
app, _settings_obj = self._app_for_modules(("tenancy", "admin"))
|
|
database = get_database()
|
|
create_scope_tables(database.engine)
|
|
Base.metadata.create_all(bind=database.engine)
|
|
with database.session() as session:
|
|
bootstrap_dev_data(session, user_password="test-admin")
|
|
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-admin-module-catalog-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
catalog_path.write_text(json.dumps({
|
|
"channel": "stable",
|
|
"sequence": 11,
|
|
"generated_at": "2026-07-10T00:00:00Z",
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"name": "Files",
|
|
"version": "0.1.6",
|
|
"action": "install",
|
|
"python_package": "govoplan-files",
|
|
"python_ref": "govoplan-files==0.1.6",
|
|
"webui_package": "@govoplan/files-webui",
|
|
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.6",
|
|
}],
|
|
}), encoding="utf-8")
|
|
|
|
with TestClient(app) as client:
|
|
login = client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": "admin@example.local", "password": "test-admin"},
|
|
)
|
|
self.assertEqual(200, login.status_code, login.text)
|
|
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
|
with patch.dict(os.environ, {
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG": str(catalog_path),
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "false",
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS": "",
|
|
}):
|
|
planned = client.post("/api/v1/admin/system/modules/install-plan/catalog/files", headers=headers)
|
|
|
|
self.assertEqual(200, planned.status_code, planned.text)
|
|
item = planned.json()["items"][0]
|
|
self.assertEqual("files", item["module_id"])
|
|
self.assertEqual("catalog", item["source"])
|
|
self.assertEqual("stable", item["catalog"]["channel"])
|
|
self.assertEqual(11, item["catalog"]["sequence"])
|
|
self.assertEqual(str(catalog_path), item["catalog"]["source"])
|
|
|
|
def test_admin_catalog_install_plan_marks_installed_module_as_update(self) -> None:
|
|
app, _settings_obj = self._app_for_modules(("tenancy", "admin", "files"))
|
|
database = get_database()
|
|
create_scope_tables(database.engine)
|
|
Base.metadata.create_all(bind=database.engine)
|
|
with database.session() as session:
|
|
bootstrap_dev_data(session, user_password="test-admin")
|
|
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-admin-module-catalog-update-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
catalog_path.write_text(json.dumps({
|
|
"channel": "stable",
|
|
"sequence": 12,
|
|
"generated_at": "2026-07-10T00:00:00Z",
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"name": "Files",
|
|
"version": "0.2.0",
|
|
"action": "install",
|
|
"python_package": "govoplan-files",
|
|
"python_ref": "govoplan-files==0.2.0",
|
|
}],
|
|
}), encoding="utf-8")
|
|
|
|
with TestClient(app) as client:
|
|
login = client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": "admin@example.local", "password": "test-admin"},
|
|
)
|
|
self.assertEqual(200, login.status_code, login.text)
|
|
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
|
with patch.dict(os.environ, {
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG": str(catalog_path),
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "false",
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS": "",
|
|
}):
|
|
planned = client.post("/api/v1/admin/system/modules/install-plan/catalog/files", headers=headers)
|
|
|
|
self.assertEqual(200, planned.status_code, planned.text)
|
|
item = planned.json()["items"][0]
|
|
self.assertEqual("files", item["module_id"])
|
|
self.assertEqual("update", item["action"])
|
|
self.assertEqual("catalog", item["source"])
|
|
|
|
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_reports_catalog_warnings_before_activation(self) -> None:
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="install",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==0.1.4",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": ["Catalog providers do not satisfy files.campaign_attachments."],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available={},
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertTrue(preflight.allowed)
|
|
self.assertIn("catalog_warning", {issue.code for issue in preflight.issues})
|
|
self.assertTrue(any("files.campaign_attachments" in issue.message for issue in preflight.issues))
|
|
|
|
def test_module_installer_preflight_blocks_invalid_catalog_sourced_install(self) -> None:
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="install",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==0.1.4",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": False,
|
|
"error": "Module package catalog must be signed by a trusted key.",
|
|
"warnings": [],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available={},
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
|
self.assertIn("catalog_validation_failed", blockers)
|
|
|
|
def test_module_installer_preflight_warns_for_invalid_catalog_during_manual_install(self) -> None:
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="install",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==0.1.4",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": False,
|
|
"error": "Module package catalog must be signed by a trusted key.",
|
|
"warnings": [],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available={},
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertTrue(preflight.allowed)
|
|
warnings = {issue.code for issue in preflight.issues if issue.severity == "warning"}
|
|
self.assertIn("catalog_validation_failed", warnings)
|
|
|
|
def test_module_installer_preflight_blocks_unsatisfied_catalog_interface_for_selected_install(self) -> None:
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="campaigns",
|
|
action="install",
|
|
source="catalog",
|
|
python_package="govoplan-campaign",
|
|
python_ref="govoplan-campaign==0.1.4",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [{
|
|
"module_id": "campaigns",
|
|
"requires_interfaces": [{
|
|
"name": "files.campaign_attachments",
|
|
"version_min": "1.0.0",
|
|
"version_max_exclusive": "2.0.0",
|
|
}],
|
|
}],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available={},
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
|
self.assertIn("catalog_required_interface_missing", blockers)
|
|
|
|
def test_module_installer_preflight_requires_companion_catalog_provider_update(self) -> None:
|
|
available = {
|
|
"files": ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="1.0.0",
|
|
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
|
|
),
|
|
"campaigns": ModuleManifest(id="campaigns", name="Campaigns", version="1.0.0"),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="campaigns",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-campaign",
|
|
python_ref="govoplan-campaign==2.0.0",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [
|
|
{
|
|
"module_id": "files",
|
|
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
|
|
},
|
|
{
|
|
"module_id": "campaigns",
|
|
"requires_interfaces": [{
|
|
"name": "files.attachments",
|
|
"version_min": "2.0.0",
|
|
"version_max_exclusive": "3.0.0",
|
|
}],
|
|
},
|
|
],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
companion_issues = [issue for issue in preflight.issues if issue.code == "companion_update_required"]
|
|
self.assertTrue(companion_issues)
|
|
self.assertTrue(any("files" in issue.message for issue in companion_issues))
|
|
|
|
def test_module_installer_suggests_catalog_companion_provider_updates(self) -> None:
|
|
available = {
|
|
"files": ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="1.0.0",
|
|
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
|
|
),
|
|
"campaigns": ModuleManifest(id="campaigns", name="Campaigns", version="1.0.0"),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="campaigns",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-campaign",
|
|
python_ref="govoplan-campaign==2.0.0",
|
|
),
|
|
))
|
|
validation = {
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [
|
|
{
|
|
"module_id": "files",
|
|
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
|
|
},
|
|
{
|
|
"module_id": "campaigns",
|
|
"requires_interfaces": [{
|
|
"name": "files.attachments",
|
|
"version_min": "2.0.0",
|
|
"version_max_exclusive": "3.0.0",
|
|
}],
|
|
},
|
|
],
|
|
}
|
|
|
|
companions = module_install_catalog_companion_module_ids(plan, available, validation=validation)
|
|
|
|
self.assertEqual(("files",), companions)
|
|
|
|
def test_module_installer_suggests_catalog_companion_consumer_updates(self) -> None:
|
|
available = {
|
|
"files": ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="1.0.0",
|
|
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
|
|
),
|
|
"campaigns": ModuleManifest(
|
|
id="campaigns",
|
|
name="Campaigns",
|
|
version="1.0.0",
|
|
requires_interfaces=(
|
|
ModuleInterfaceRequirement(
|
|
name="files.attachments",
|
|
version_min="1.0.0",
|
|
version_max_exclusive="2.0.0",
|
|
),
|
|
),
|
|
),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
),
|
|
))
|
|
validation = {
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [
|
|
{
|
|
"module_id": "files",
|
|
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
|
|
},
|
|
{
|
|
"module_id": "campaigns",
|
|
"requires_interfaces": [{
|
|
"name": "files.attachments",
|
|
"version_min": "2.0.0",
|
|
"version_max_exclusive": "3.0.0",
|
|
}],
|
|
},
|
|
],
|
|
}
|
|
|
|
companions = module_install_catalog_companion_module_ids(plan, available, validation=validation)
|
|
|
|
self.assertEqual(("campaigns",), companions)
|
|
|
|
def test_module_installer_preflight_blocks_unacknowledged_forward_only_catalog_update(self) -> None:
|
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"version": "2.0.0",
|
|
"migration_safety": "forward_only",
|
|
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
|
"recovery_tested": True,
|
|
"recovery_notes": "Snapshot restore was rehearsed on the staging dataset.",
|
|
}],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
|
self.assertIn("forward_only_migration_acknowledgement_required", blockers)
|
|
|
|
def test_module_installer_preflight_requires_recovery_validation_for_forward_only_update(self) -> None:
|
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
data_safety_acknowledged=True,
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"version": "2.0.0",
|
|
"migration_safety": "forward_only",
|
|
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
|
}],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
|
self.assertIn("recovery_validation_required", blockers)
|
|
self.assertIn("recovery_notes_required", blockers)
|
|
|
|
def test_module_installer_preflight_allows_acknowledged_forward_only_catalog_update(self) -> None:
|
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
data_safety_acknowledged=True,
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"version": "2.0.0",
|
|
"migration_safety": "forward_only",
|
|
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
|
"recovery_tested": True,
|
|
"recovery_notes": "Snapshot restore was rehearsed on the staging dataset.",
|
|
}],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertTrue(preflight.allowed)
|
|
self.assertEqual(("files",), tuple(item.module_id for item in preflight.target_plan))
|
|
self.assertEqual("1.0.0", preflight.target_plan[0].current_version)
|
|
self.assertEqual("2.0.0", preflight.target_plan[0].target_version)
|
|
self.assertEqual("forward_only", preflight.target_plan[0].migration_safety)
|
|
self.assertTrue(preflight.target_plan[0].recovery_tested)
|
|
self.assertEqual("Snapshot restore was rehearsed on the staging dataset.", preflight.target_plan[0].recovery_notes)
|
|
self.assertTrue(preflight.target_plan[0].data_safety_acknowledged)
|
|
self.assertEqual("2.0.0", preflight.as_dict()["target_plan"][0]["target_version"])
|
|
warnings = {issue.code for issue in preflight.issues if issue.severity == "warning"}
|
|
self.assertIn("forward_only_migration_acknowledged", warnings)
|
|
|
|
def test_module_installer_preflight_blocks_catalog_downgrade_and_noop_updates_by_default(self) -> None:
|
|
available = {
|
|
"files": ModuleManifest(id="files", name="Files", version="1.0.0"),
|
|
"mail": ModuleManifest(id="mail", name="Mail", version="1.0.0"),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==0.9.0",
|
|
),
|
|
ModuleInstallPlanItem(
|
|
module_id="mail",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-mail",
|
|
python_ref="govoplan-mail==1.0.0",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [
|
|
{"module_id": "files", "version": "0.9.0"},
|
|
{"module_id": "mail", "version": "1.0.0"},
|
|
],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
|
self.assertIn("catalog_downgrade_blocked", blockers)
|
|
self.assertIn("catalog_noop_update_blocked", blockers)
|
|
|
|
def test_module_installer_preflight_allows_reviewed_downgrade_and_noop_updates(self) -> None:
|
|
available = {
|
|
"files": ModuleManifest(id="files", name="Files", version="1.0.0"),
|
|
"mail": ModuleManifest(id="mail", name="Mail", version="1.0.0"),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==0.9.0",
|
|
),
|
|
ModuleInstallPlanItem(
|
|
module_id="mail",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-mail",
|
|
python_ref="govoplan-mail==1.0.0",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [
|
|
{"module_id": "files", "version": "0.9.0", "allow_downgrade": True},
|
|
{"module_id": "mail", "version": "1.0.0", "allow_same_version": True},
|
|
],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertTrue(preflight.allowed)
|
|
blocker_codes = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
|
self.assertNotIn("catalog_downgrade_blocked", blocker_codes)
|
|
self.assertNotIn("catalog_noop_update_blocked", blocker_codes)
|
|
|
|
def test_module_installer_preflight_requires_catalog_update_bridge_when_current_version_is_outside_window(self) -> None:
|
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==3.0.0",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"version": "3.0.0",
|
|
"current_version_min": "2.0.0",
|
|
"current_version_max_exclusive": "3.0.0",
|
|
"bridge_notes": "Install 2.x bridge before moving to 3.x.",
|
|
}],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
|
self.assertIn("catalog_update_bridge_required", blockers)
|
|
self.assertEqual("2.0.0", preflight.target_plan[0].current_version_min)
|
|
self.assertEqual("3.0.0", preflight.target_plan[0].current_version_max_exclusive)
|
|
|
|
def test_module_installer_preflight_reports_catalog_bridge_release_metadata(self) -> None:
|
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"version": "2.0.0",
|
|
"bridge_release": True,
|
|
"bridge_notes": "Keeps both 1.x and 2.x attachment interfaces available.",
|
|
}],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertTrue(preflight.allowed)
|
|
self.assertIn("catalog_bridge_release", {issue.code for issue in preflight.issues if issue.severity == "info"})
|
|
self.assertTrue(preflight.target_plan[0].bridge_release)
|
|
self.assertEqual("Keeps both 1.x and 2.x attachment interfaces available.", preflight.target_plan[0].bridge_notes)
|
|
|
|
def test_module_installer_preflight_requires_destructive_catalog_update_plan(self) -> None:
|
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
data_safety_acknowledged=True,
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"migration_safety": "destructive",
|
|
}],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
|
self.assertIn("destructive_migration_plan_required", blockers)
|
|
|
|
def test_module_installer_preflight_allows_acknowledged_destructive_catalog_update_with_plan(self) -> None:
|
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
data_safety_acknowledged=True,
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"migration_safety": "destructive",
|
|
"migration_notes": "Drops obsolete cache tables after exporting the retained documents.",
|
|
"recovery_tested": True,
|
|
"recovery_notes": "Restore and forward-recovery path verified on staging.",
|
|
}],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertTrue(preflight.allowed)
|
|
warnings = {issue.code for issue in preflight.issues if issue.severity == "warning"}
|
|
self.assertIn("destructive_migration_acknowledged", warnings)
|
|
|
|
def test_module_installer_preflight_allows_companion_updates_in_same_target_set(self) -> None:
|
|
available = {
|
|
"files": ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="1.0.0",
|
|
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
|
|
),
|
|
"campaigns": ModuleManifest(id="campaigns", name="Campaigns", version="1.0.0"),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
),
|
|
ModuleInstallPlanItem(
|
|
module_id="campaigns",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-campaign",
|
|
python_ref="govoplan-campaign==2.0.0",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [
|
|
{
|
|
"module_id": "files",
|
|
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
|
|
},
|
|
{
|
|
"module_id": "campaigns",
|
|
"requires_interfaces": [{
|
|
"name": "files.attachments",
|
|
"version_min": "2.0.0",
|
|
"version_max_exclusive": "3.0.0",
|
|
}],
|
|
},
|
|
],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertTrue(preflight.allowed)
|
|
self.assertNotIn("companion_update_required", {issue.code for issue in preflight.issues})
|
|
|
|
def test_module_installer_preflight_orders_migration_plan_from_interface_requirements(self) -> None:
|
|
metadata = MetaData()
|
|
available = {
|
|
"files": ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="1.0.0",
|
|
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
|
|
migration_spec=MigrationSpec(module_id="files", metadata=metadata),
|
|
),
|
|
"campaigns": ModuleManifest(
|
|
id="campaigns",
|
|
name="Campaigns",
|
|
version="1.0.0",
|
|
requires_interfaces=(
|
|
ModuleInterfaceRequirement(
|
|
name="files.attachments",
|
|
version_min="1.0.0",
|
|
version_max_exclusive="2.0.0",
|
|
),
|
|
),
|
|
migration_spec=MigrationSpec(module_id="campaigns", metadata=metadata),
|
|
),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="campaigns",
|
|
action="update",
|
|
python_package="govoplan-campaign",
|
|
python_ref="govoplan-campaign==2.0.0",
|
|
),
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
),
|
|
))
|
|
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=("files", "campaigns"),
|
|
desired_enabled=("files", "campaigns"),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertTrue(preflight.allowed)
|
|
self.assertEqual(("files", "campaigns"), tuple(step.module_id for step in preflight.migration_plan.steps))
|
|
self.assertEqual(("files", "campaigns"), tuple(preflight.migration_plan.enabled_modules))
|
|
self.assertEqual(["files", "campaigns"], preflight.as_dict()["migration_plan"]["enabled_modules"])
|
|
|
|
def test_module_installer_preflight_blocks_migration_graph_cycles(self) -> None:
|
|
metadata = MetaData()
|
|
available = {
|
|
"files": ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="1.0.0",
|
|
migration_spec=MigrationSpec(module_id="files", metadata=metadata, migration_after=("campaigns",)),
|
|
),
|
|
"campaigns": ModuleManifest(
|
|
id="campaigns",
|
|
name="Campaigns",
|
|
version="1.0.0",
|
|
migration_spec=MigrationSpec(module_id="campaigns", metadata=metadata, migration_after=("files",)),
|
|
),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="campaigns",
|
|
action="update",
|
|
python_package="govoplan-campaign",
|
|
python_ref="govoplan-campaign==2.0.0",
|
|
),
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
),
|
|
))
|
|
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=("files", "campaigns"),
|
|
desired_enabled=("files", "campaigns"),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
|
self.assertIn("migration_graph_cycle", blockers)
|
|
|
|
def test_structured_install_commands_pass_target_modules_and_migration_order(self) -> None:
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="install",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
),
|
|
))
|
|
|
|
commands = structured_install_commands(
|
|
plan,
|
|
webui_root=None,
|
|
migrate_database_url="sqlite:///example.db",
|
|
migration_enabled_modules=("access", "files"),
|
|
migration_module_order=("files",),
|
|
migration_task_record_path=Path("/tmp/govoplan-migration-tasks.json"),
|
|
verify_modules=True,
|
|
)
|
|
|
|
init_db = commands[-1]["argv"]
|
|
self.assertIn("govoplan_core.commands.init_db", init_db)
|
|
self.assertIn("--enabled-module", init_db)
|
|
self.assertIn("access", init_db)
|
|
self.assertIn("files", init_db)
|
|
self.assertIn("--migration-module", init_db)
|
|
self.assertIn("--migration-task-record-output", init_db)
|
|
self.assertEqual("/tmp/govoplan-migration-tasks.json", commands[-1]["migration_task_record_path"])
|
|
|
|
def test_module_installer_preflight_reports_and_blocks_unsafe_migration_tasks(self) -> None:
|
|
metadata = MetaData()
|
|
available = {
|
|
"files": ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="1.0.0",
|
|
migration_spec=MigrationSpec(
|
|
module_id="files",
|
|
metadata=metadata,
|
|
migration_tasks=(
|
|
ModuleMigrationTask(
|
|
task_id="legacy_path_check",
|
|
phase="pre_migration_check",
|
|
summary="Check legacy paths before the schema migration.",
|
|
executor=lambda _context: ModuleMigrationTaskResult(),
|
|
),
|
|
ModuleMigrationTask(
|
|
task_id="unsafe_backfill",
|
|
phase="post_migration_backfill",
|
|
summary="Backfill legacy file spaces.",
|
|
safety="forward_only",
|
|
idempotent=False,
|
|
executor=lambda _context: ModuleMigrationTaskResult(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
),
|
|
))
|
|
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=("files",),
|
|
desired_enabled=("files",),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
task_ids = {task.task_id for task in preflight.migration_plan.tasks}
|
|
self.assertEqual({"legacy_path_check", "unsafe_backfill"}, task_ids)
|
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
|
self.assertIn("migration_task_not_idempotent", blockers)
|
|
self.assertIn("migration_task_acknowledgement_required", blockers)
|
|
|
|
def test_module_installer_preflight_allows_acknowledged_forward_only_migration_task(self) -> None:
|
|
metadata = MetaData()
|
|
available = {
|
|
"files": ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="1.0.0",
|
|
migration_spec=MigrationSpec(
|
|
module_id="files",
|
|
metadata=metadata,
|
|
migration_tasks=(
|
|
ModuleMigrationTask(
|
|
task_id="backfill_spaces",
|
|
phase="post_migration_backfill",
|
|
summary="Backfill file spaces.",
|
|
safety="forward_only",
|
|
executor=lambda _context: ModuleMigrationTaskResult(status="ok"),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
data_safety_acknowledged=True,
|
|
),
|
|
))
|
|
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=("files",),
|
|
desired_enabled=("files",),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertTrue(preflight.allowed)
|
|
self.assertEqual(("backfill_spaces",), tuple(task.task_id for task in preflight.migration_plan.tasks))
|
|
self.assertIn("tasks", preflight.as_dict()["migration_plan"])
|
|
|
|
def test_registered_module_migration_tasks_execute_in_phase_order(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-migration-tasks-", dir=_TEST_ROOT))
|
|
database_url = f"sqlite:///{root / 'tasks.db'}"
|
|
calls: list[tuple[str, str, bool, str | None]] = []
|
|
|
|
def executor(context: ModuleMigrationTaskContext) -> ModuleMigrationTaskResult:
|
|
calls.append((context.module_id, context.task_id, context.session is not None, context.target_version))
|
|
return ModuleMigrationTaskResult(status="warning", message=f"ran {context.task_id}", details={"phase": context.phase})
|
|
|
|
manifest = ModuleManifest(
|
|
id="taskmod",
|
|
name="Task Module",
|
|
version="2.0.0",
|
|
migration_spec=MigrationSpec(
|
|
module_id="taskmod",
|
|
metadata=MetaData(),
|
|
migration_tasks=(
|
|
ModuleMigrationTask(
|
|
task_id="verify",
|
|
phase="post_migration_verify",
|
|
summary="Verify migrated state.",
|
|
executor=executor,
|
|
),
|
|
ModuleMigrationTask(
|
|
task_id="check",
|
|
phase="pre_migration_check",
|
|
summary="Check source state.",
|
|
executor=executor,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
records = run_registered_module_migration_tasks(
|
|
database_url=database_url,
|
|
enabled_modules=("taskmod",),
|
|
migration_module_order=("taskmod",),
|
|
phases=("pre_migration_check", "post_migration_verify"),
|
|
manifest_factories=(lambda: manifest,),
|
|
)
|
|
|
|
self.assertEqual(
|
|
[("taskmod", "check", True, "2.0.0"), ("taskmod", "verify", True, "2.0.0")],
|
|
calls,
|
|
)
|
|
self.assertEqual(["check", "verify"], [record["task_id"] for record in records])
|
|
self.assertEqual(["warning", "warning"], [record["status"] for record in records])
|
|
|
|
def test_module_installer_preflight_blocks_provider_update_that_breaks_installed_consumer(self) -> None:
|
|
available = {
|
|
"files": ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="1.0.0",
|
|
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
|
|
),
|
|
"campaigns": ModuleManifest(
|
|
id="campaigns",
|
|
name="Campaigns",
|
|
version="1.0.0",
|
|
requires_interfaces=(
|
|
ModuleInterfaceRequirement(
|
|
name="files.attachments",
|
|
version_min="1.0.0",
|
|
version_max_exclusive="2.0.0",
|
|
),
|
|
),
|
|
),
|
|
}
|
|
plan = ModuleInstallPlan(items=(
|
|
ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="update",
|
|
source="catalog",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==2.0.0",
|
|
),
|
|
))
|
|
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
|
return_value={
|
|
"configured": True,
|
|
"valid": True,
|
|
"warnings": [],
|
|
"modules": [
|
|
{
|
|
"module_id": "files",
|
|
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
|
|
},
|
|
{
|
|
"module_id": "campaigns",
|
|
"requires_interfaces": [{
|
|
"name": "files.attachments",
|
|
"version_min": "2.0.0",
|
|
"version_max_exclusive": "3.0.0",
|
|
}],
|
|
},
|
|
],
|
|
},
|
|
):
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available,
|
|
current_enabled=(),
|
|
desired_enabled=(),
|
|
maintenance_mode=True,
|
|
)
|
|
|
|
self.assertFalse(preflight.allowed)
|
|
companion_issues = [issue for issue in preflight.issues if issue.code == "companion_update_required"]
|
|
self.assertTrue(companion_issues)
|
|
self.assertTrue(any("campaigns" in issue.message for issue in companion_issues))
|
|
|
|
def test_module_installer_reports_interface_contract_issues(self) -> None:
|
|
available = {
|
|
"files": ModuleManifest(
|
|
id="files",
|
|
name="Files",
|
|
version="2.0.0",
|
|
provides_interfaces=(ModuleInterfaceProvider(name="files.spaces", version="2.0.0"),),
|
|
),
|
|
"campaigns": ModuleManifest(
|
|
id="campaigns",
|
|
name="Campaigns",
|
|
version="1.1.0",
|
|
requires_interfaces=(
|
|
ModuleInterfaceRequirement(
|
|
name="files.spaces",
|
|
version_min="1.0.0",
|
|
version_max_exclusive="2.0.0",
|
|
),
|
|
ModuleInterfaceRequirement(name="mail.delivery", version_min="1.0.0"),
|
|
),
|
|
),
|
|
}
|
|
|
|
issues = module_manifest_compatibility_issues(available)
|
|
|
|
self.assertIn("interface_version_mismatch", {issue.code for issue in issues})
|
|
self.assertIn("required_interface_missing", {issue.code for issue in issues})
|
|
|
|
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_module_installer_records_verified_local_artifact_integrity(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-artifact-integrity-", dir=_TEST_ROOT))
|
|
artifact_path = root / "govoplan-files-0.1.4-py3-none-any.whl"
|
|
artifact_path.write_bytes(b"package artifact")
|
|
digest = hashlib.sha256(artifact_path.read_bytes()).hexdigest()
|
|
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",
|
|
"artifact_integrity": {
|
|
"python": {
|
|
"ref": "govoplan-files==0.1.4",
|
|
"path": str(artifact_path),
|
|
"sha256": digest,
|
|
"sbom_url": "https://govoplan.example/sbom/files-0.1.4.spdx.json",
|
|
"provenance_url": "https://govoplan.example/provenance/files-0.1.4.intoto.jsonl",
|
|
},
|
|
},
|
|
}])
|
|
session.commit()
|
|
|
|
preflight = module_install_preflight(
|
|
plan=plan,
|
|
available=available_module_manifests(),
|
|
current_enabled=("tenancy", "access"),
|
|
desired_enabled=("tenancy", "access"),
|
|
maintenance_mode=True,
|
|
runtime_dir=root / "installer",
|
|
)
|
|
self.assertTrue(preflight.allowed)
|
|
self.assertEqual(digest, preflight.as_dict()["artifact_integrity"][0]["actual_sha256"])
|
|
|
|
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",
|
|
dry_run=True,
|
|
)
|
|
|
|
record = read_module_installer_run(runtime_dir=root / "installer", run_id=result.run_id)
|
|
verified = record["preflight"]["artifact_integrity"][0]
|
|
self.assertTrue(verified["verified"])
|
|
self.assertEqual(str(artifact_path), verified["artifact_path"])
|
|
|
|
def test_module_installer_blocks_missing_integrity_in_production_mode(self) -> None:
|
|
plan = ModuleInstallPlan(items=(ModuleInstallPlanItem(
|
|
module_id="files",
|
|
action="install",
|
|
python_package="govoplan-files",
|
|
python_ref="govoplan-files==0.1.4",
|
|
),))
|
|
|
|
with patch.dict(os.environ, {"GOVOPLAN_MODULE_INSTALLER_REQUIRE_ARTIFACT_INTEGRITY": "true"}):
|
|
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("artifact_integrity_required", {issue.code for issue in preflight.issues})
|
|
|
|
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', "
|
|
"'pgtools_url': os.environ.get('GOVOPLAN_DATABASE_URL_PGTOOLS')"
|
|
"}), 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+psycopg://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("external", database_backup["metadata"]["snapshot"])
|
|
self.assertEqual("postgresql://db.example.invalid/govoplan", database_backup["metadata"]["pgtools_url"])
|
|
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"
|
|
|
|
with event_context(correlation_id="installer-trace-1"):
|
|
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("installer-trace-1", queued["trace"]["correlation_id"])
|
|
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"])
|
|
self.assertEqual("installer-trace-1", claimed["trace"]["correlation_id"])
|
|
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"])
|
|
|
|
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=runtime_dir,
|
|
dry_run=True,
|
|
request_context={
|
|
"request_id": request_id,
|
|
"requested_by": "user-1",
|
|
"trace": queued["trace"],
|
|
},
|
|
)
|
|
|
|
record = json.loads(result.record_path.read_text(encoding="utf-8"))
|
|
self.assertEqual(request_id, record["request"]["request_id"])
|
|
self.assertEqual("installer-trace-1", record["request"]["trace"]["correlation_id"])
|
|
run_summary = list_module_installer_runs(runtime_dir=runtime_dir)[0]
|
|
self.assertEqual(request_id, run_summary["request_id"])
|
|
self.assertEqual("installer-trace-1", run_summary["trace"]["correlation_id"])
|
|
|
|
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",
|
|
"dependencies": ["access"],
|
|
"optional_dependencies": ["mail"],
|
|
"migration_safety": "forward_only",
|
|
"migration_notes": "Requires a tested backup restore path.",
|
|
"migration_after": ["access"],
|
|
"migration_before": ["campaigns"],
|
|
"migration_tasks": [{
|
|
"task_id": "backfill_spaces",
|
|
"phase": "post_migration_backfill",
|
|
"summary": "Backfill file spaces after schema migration.",
|
|
"task_version": "2",
|
|
"safety": "forward_only",
|
|
"idempotent": True,
|
|
"timeout_seconds": 30,
|
|
}],
|
|
"current_version_min": "0.1.0",
|
|
"current_version_max_exclusive": "0.2.0",
|
|
"bridge_release": True,
|
|
"bridge_notes": "Keeps 0.1.x and 0.2.x file-space contracts available.",
|
|
"allow_downgrade": True,
|
|
"allow_same_version": True,
|
|
"recovery_tested": True,
|
|
"recovery_notes": "Restore rehearsal completed for the release candidate.",
|
|
"provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}],
|
|
"requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}],
|
|
"artifact_integrity": {
|
|
"python": {
|
|
"ref": "govoplan-files==0.1.4",
|
|
"sha256": "0" * 64,
|
|
"sbom_url": "https://govoplan.example/sbom/files-0.1.4.spdx.json",
|
|
"provenance_url": "https://govoplan.example/provenance/files-0.1.4.intoto.jsonl",
|
|
}
|
|
},
|
|
"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(["access"], catalog[0]["dependencies"])
|
|
self.assertEqual(["mail"], catalog[0]["optional_dependencies"])
|
|
self.assertEqual("forward_only", catalog[0]["migration_safety"])
|
|
self.assertEqual("Requires a tested backup restore path.", catalog[0]["migration_notes"])
|
|
self.assertEqual(["access"], catalog[0]["migration_after"])
|
|
self.assertEqual(["campaigns"], catalog[0]["migration_before"])
|
|
self.assertEqual([{
|
|
"task_id": "backfill_spaces",
|
|
"phase": "post_migration_backfill",
|
|
"summary": "Backfill file spaces after schema migration.",
|
|
"task_version": "2",
|
|
"safety": "forward_only",
|
|
"idempotent": True,
|
|
"timeout_seconds": 30,
|
|
}], catalog[0]["migration_tasks"])
|
|
self.assertEqual("0.1.0", catalog[0]["current_version_min"])
|
|
self.assertEqual("0.2.0", catalog[0]["current_version_max_exclusive"])
|
|
self.assertTrue(catalog[0]["bridge_release"])
|
|
self.assertEqual("Keeps 0.1.x and 0.2.x file-space contracts available.", catalog[0]["bridge_notes"])
|
|
self.assertTrue(catalog[0]["allow_downgrade"])
|
|
self.assertTrue(catalog[0]["allow_same_version"])
|
|
self.assertTrue(catalog[0]["recovery_tested"])
|
|
self.assertEqual("Restore rehearsal completed for the release candidate.", catalog[0]["recovery_notes"])
|
|
self.assertEqual(["official"], catalog[0]["tags"])
|
|
self.assertEqual([{"name": "files.spaces", "version": "1.2.0"}], catalog[0]["provides_interfaces"])
|
|
self.assertEqual(
|
|
[{"name": "access.directory", "optional": True, "version_min": "1.0.0"}],
|
|
catalog[0]["requires_interfaces"],
|
|
)
|
|
self.assertEqual("0" * 64, catalog[0]["artifact_integrity"]["python"]["sha256"])
|
|
|
|
validation = validate_module_package_catalog(catalog_path)
|
|
self.assertTrue(validation["valid"])
|
|
self.assertEqual("files", validation["modules"][0]["module_id"])
|
|
|
|
def test_module_package_catalog_warns_about_interface_range_mismatch(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-interfaces-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
catalog_path.write_text(json.dumps({
|
|
"modules": [
|
|
{
|
|
"module_id": "files",
|
|
"version": "2.0.0",
|
|
"python_package": "govoplan-files",
|
|
"python_ref": "govoplan-files==2.0.0",
|
|
"provides_interfaces": [{"name": "files.spaces", "version": "2.0.0"}],
|
|
},
|
|
{
|
|
"module_id": "campaigns",
|
|
"version": "1.1.0",
|
|
"python_package": "govoplan-campaign",
|
|
"python_ref": "govoplan-campaign==1.1.0",
|
|
"requires_interfaces": [{
|
|
"name": "files.spaces",
|
|
"version_min": "1.0.0",
|
|
"version_max_exclusive": "2.0.0",
|
|
}],
|
|
},
|
|
],
|
|
}), encoding="utf-8")
|
|
|
|
validation = validate_module_package_catalog(catalog_path)
|
|
|
|
self.assertTrue(validation["valid"])
|
|
self.assertTrue(any("catalog providers" in warning for warning in validation["warnings"]))
|
|
|
|
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_rejects_invalid_migration_safety(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-safety-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
catalog_path.write_text(json.dumps({
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"migration_safety": "maybe",
|
|
}],
|
|
}), encoding="utf-8")
|
|
|
|
validation = validate_module_package_catalog(catalog_path)
|
|
|
|
self.assertFalse(validation["valid"])
|
|
self.assertIn("migration_safety", str(validation["error"]))
|
|
|
|
def test_module_package_catalog_rejects_invalid_current_version_window(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-current-window-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
catalog_path.write_text(json.dumps({
|
|
"modules": [{
|
|
"module_id": "files",
|
|
"current_version_min": "2.0.0",
|
|
"current_version_max_exclusive": "2.0.0",
|
|
}],
|
|
}), encoding="utf-8")
|
|
|
|
validation = validate_module_package_catalog(catalog_path)
|
|
|
|
self.assertFalse(validation["valid"])
|
|
self.assertIn("current-version range", str(validation["error"]))
|
|
|
|
def test_module_package_catalog_rejects_invalid_interface_ranges(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-interface-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
catalog_path.write_text(json.dumps({
|
|
"modules": [{
|
|
"module_id": "campaigns",
|
|
"python_package": "govoplan-campaign",
|
|
"python_ref": "govoplan-campaign==1.1.0",
|
|
"requires_interfaces": [{
|
|
"name": "files.spaces",
|
|
"version_min": "2.0.0",
|
|
"version_max_exclusive": "2.0.0",
|
|
}],
|
|
}],
|
|
}), encoding="utf-8")
|
|
|
|
validation = validate_module_package_catalog(catalog_path)
|
|
|
|
self.assertFalse(validation["valid"])
|
|
self.assertIn("invalid interface range", str(validation["error"]))
|
|
|
|
def test_module_package_catalog_requires_signature_by_default_in_production(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-production-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
catalog_path.write_text(json.dumps({
|
|
"channel": "stable",
|
|
"sequence": 1,
|
|
"modules": [{"module_id": "files"}],
|
|
}), encoding="utf-8")
|
|
|
|
with patch.dict(os.environ, {
|
|
"APP_ENV": "production",
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "",
|
|
}):
|
|
validation = validate_module_package_catalog(catalog_path)
|
|
|
|
self.assertFalse(validation["valid"])
|
|
self.assertIn("signed", str(validation["error"]))
|
|
|
|
with patch.dict(os.environ, {
|
|
"APP_ENV": "production",
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "false",
|
|
}):
|
|
relaxed = validate_module_package_catalog(catalog_path)
|
|
|
|
self.assertTrue(relaxed["valid"], relaxed["error"])
|
|
self.assertTrue(any("unsigned" in warning for warning in relaxed["warnings"]))
|
|
|
|
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_release_catalog_generator_reads_manifest_interface_contracts(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-release-catalog-generator-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"scripts/generate-release-catalog.py",
|
|
"--version",
|
|
"0.1.7",
|
|
"--catalog-output",
|
|
str(catalog_path),
|
|
],
|
|
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)
|
|
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
modules = {item["module_id"]: item for item in catalog["modules"]}
|
|
|
|
self.assertIn({"name": "files.campaign_attachments", "version": "0.1.6"}, modules["files"]["provides_interfaces"])
|
|
self.assertIn({
|
|
"name": "campaigns.access",
|
|
"optional": True,
|
|
"version_min": "0.1.0",
|
|
"version_max_exclusive": "0.2.0",
|
|
}, modules["files"]["requires_interfaces"])
|
|
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
|
|
self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"])
|
|
self.assertIn({
|
|
"name": "files.campaign_attachments",
|
|
"optional": True,
|
|
"version_min": "0.1.0",
|
|
"version_max_exclusive": "0.2.0",
|
|
}, modules["campaigns"]["requires_interfaces"])
|
|
self.assertEqual(["files", "mail"], modules["campaigns"]["optional_dependencies"])
|
|
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
|
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
|
self.assertEqual("0.1.7", modules["files"]["version"])
|
|
self.assertIn("@v0.1.7", modules["files"]["python_ref"])
|
|
|
|
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
private_key_path = root / "catalog-private.pem"
|
|
signed_path = root / "catalog.signed.json"
|
|
cache_path = root / "catalog-cache.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",
|
|
"sequence": 42,
|
|
"generated_at": "2026-07-07T00:00:00Z",
|
|
"expires_at": "2030-12-31T23:59:59Z",
|
|
"modules": [{"module_id": "files"}],
|
|
}), encoding="utf-8")
|
|
sign_module_package_catalog(
|
|
path=catalog_path,
|
|
key_id="release-remote",
|
|
private_key_path=private_key_path,
|
|
output_path=signed_path,
|
|
)
|
|
body = signed_path.read_text(encoding="utf-8")
|
|
|
|
class _Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
return False
|
|
|
|
def read(self) -> bytes:
|
|
return body.encode("utf-8")
|
|
|
|
env = {
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_URL": "https://catalog.example.invalid/stable.json",
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE": str(cache_path),
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "true",
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS": "stable",
|
|
}
|
|
trusted_keys = {"release-remote": base64.b64encode(public_key).decode("ascii")}
|
|
with patch.dict(os.environ, env):
|
|
with patch("govoplan_core.core.module_package_catalog.urllib.request.urlopen", return_value=_Response()):
|
|
fetched = validate_module_package_catalog(trusted_keys=trusted_keys)
|
|
with patch(
|
|
"govoplan_core.core.module_package_catalog.urllib.request.urlopen",
|
|
side_effect=urllib.error.URLError("offline"),
|
|
):
|
|
cached = validate_module_package_catalog(trusted_keys=trusted_keys)
|
|
|
|
self.assertTrue(fetched["valid"], fetched["error"])
|
|
self.assertEqual("url", fetched["source_type"])
|
|
self.assertFalse(fetched["cache_used"])
|
|
self.assertEqual("stable", fetched["channel"])
|
|
self.assertEqual(42, fetched["sequence"])
|
|
self.assertTrue(fetched["trusted"])
|
|
self.assertEqual("release-remote", fetched["key_id"])
|
|
self.assertTrue(cache_path.exists())
|
|
self.assertTrue(cached["valid"], cached["error"])
|
|
self.assertTrue(cached["cache_used"])
|
|
self.assertEqual(str(cache_path), cached["cache_path"])
|
|
self.assertTrue(cached["trusted"])
|
|
|
|
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_rejects_not_before_catalog(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-not-before-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
catalog_path.write_text(json.dumps({
|
|
"channel": "stable",
|
|
"sequence": 1,
|
|
"generated_at": "2026-07-07T00:00:00Z",
|
|
"not_before": "2030-01-01T00:00:00Z",
|
|
"expires_at": "2030-12-31T23:59:59Z",
|
|
"modules": [{"module_id": "files"}],
|
|
}), encoding="utf-8")
|
|
|
|
validation = validate_module_package_catalog(catalog_path)
|
|
|
|
self.assertFalse(validation["valid"])
|
|
self.assertEqual("2030-01-01T00:00:00Z", validation["not_before"])
|
|
self.assertIn("not valid before", 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"
|
|
older_catalog_path = root / "catalog-older.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")
|
|
older_catalog_path.write_text(json.dumps({
|
|
"channel": "stable",
|
|
"sequence": 6,
|
|
"generated_at": "2026-07-06T00: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)
|
|
older = validate_module_package_catalog(older_catalog_path)
|
|
with patch.dict(os.environ, {
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_SEQUENCE_STATE": str(state_path),
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_ENFORCE_SEQUENCE": "",
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS": "",
|
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "",
|
|
}):
|
|
same_non_strict = validate_module_package_catalog(catalog_path)
|
|
|
|
self.assertFalse(replay["valid"])
|
|
self.assertIn("already accepted", str(replay["error"]))
|
|
self.assertFalse(older["valid"])
|
|
self.assertIn("older than accepted", str(older["error"]))
|
|
self.assertTrue(same_non_strict["valid"], same_non_strict["error"])
|
|
|
|
def test_configuration_package_catalog_validates_signed_entries(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-configuration-package-catalog-", dir=_TEST_ROOT))
|
|
catalog_path = root / "catalog.json"
|
|
private_key_path = root / "configuration-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",
|
|
"sequence": 3,
|
|
"generated_at": "2026-07-07T00:00:00Z",
|
|
"expires_at": "2030-12-31T23:59:59Z",
|
|
"packages": [
|
|
{
|
|
"package_id": "govoplan.application-handling.basic",
|
|
"name": "Basic application handling",
|
|
"version": "0.1.0",
|
|
"publisher": "ADD ideas",
|
|
"category": "workflow",
|
|
"artifact_ref": "git+ssh://git.example.invalid/config-packages.git@application-basic-v0.1.0",
|
|
"artifact_sha256": "a" * 64,
|
|
"required_modules": [{"module_id": "access", "version": ">=0.1.0"}],
|
|
"required_capabilities": [CONFIGURATION_PROVIDER_CAPABILITY, "access.configuration"],
|
|
"fragments": [
|
|
{
|
|
"module_id": "access",
|
|
"fragment_type": "access_roles",
|
|
"fragment_id": "clerks",
|
|
"payload": {"access_roles": [{"slug": "application-clerk"}]},
|
|
}
|
|
],
|
|
"data_requirements": [{"key": "service_name", "label": "Service name"}],
|
|
"tags": ["official", "example"],
|
|
}
|
|
],
|
|
}), encoding="utf-8")
|
|
|
|
unsigned = validate_configuration_package_catalog(catalog_path)
|
|
self.assertTrue(unsigned["valid"], unsigned["error"])
|
|
self.assertFalse(unsigned["signed"])
|
|
self.assertEqual("govoplan.application-handling.basic", unsigned["packages"][0]["package_id"])
|
|
self.assertEqual("access", unsigned["packages"][0]["required_modules"][0]["module_id"])
|
|
self.assertIn("unsigned", " ".join(unsigned["warnings"]).lower())
|
|
|
|
sign_configuration_package_catalog(
|
|
path=catalog_path,
|
|
key_id="configuration-release-1",
|
|
private_key_path=private_key_path,
|
|
output_path=signed_path,
|
|
)
|
|
validation = validate_configuration_package_catalog(
|
|
signed_path,
|
|
require_trusted=True,
|
|
approved_channels=("stable",),
|
|
trusted_keys={"configuration-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"])
|
|
|
|
catalog = configuration_package_catalog(
|
|
signed_path,
|
|
require_trusted=True,
|
|
approved_channels=("stable",),
|
|
trusted_keys={"configuration-release-1": base64.b64encode(public_key).decode("ascii")},
|
|
)
|
|
self.assertEqual(("official", "example"), tuple(catalog[0]["tags"]))
|
|
|
|
def test_configuration_provider_contract_is_runtime_checkable(self) -> None:
|
|
class AccessConfigurationProvider:
|
|
module_id = "access"
|
|
|
|
def describe(self) -> ConfigurationProviderDescription:
|
|
return ConfigurationProviderDescription(module_id=self.module_id, fragment_types=("access_roles",), exported_scopes=("tenant",))
|
|
|
|
def preflight(self, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
|
del context
|
|
return ConfigurationPreflightResult(
|
|
required_data=(ConfigurationRequiredData(key="service_name", label="Service name"),),
|
|
plan=(ConfigurationPlanItem(action="create", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id),),
|
|
)
|
|
|
|
def apply(self, fragment: ConfigurationPackageFragment, supplied_data: dict[str, object], context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
|
del supplied_data, context
|
|
return ConfigurationApplyResult(created_refs={fragment.fragment_id or fragment.fragment_type: "role:application-clerk"})
|
|
|
|
def export(self, selection: ConfigurationExportSelection, context: ConfigurationPreflightContext) -> ConfigurationExportResult:
|
|
del selection, context
|
|
return ConfigurationExportResult(fragments=(ConfigurationPackageFragment(module_id=self.module_id, fragment_type="access_roles", fragment_id="clerks"),))
|
|
|
|
def health(self, import_result: ConfigurationApplyResult, context: ConfigurationPreflightContext):
|
|
del import_result, context
|
|
return ()
|
|
|
|
provider = AccessConfigurationProvider()
|
|
fragment = ConfigurationPackageFragment(module_id="access", fragment_type="access_roles", fragment_id="clerks")
|
|
|
|
self.assertIsInstance(provider, ConfigurationProvider)
|
|
self.assertEqual(("access_roles",), provider.describe().fragment_types)
|
|
self.assertEqual("service_name", provider.preflight(fragment, ConfigurationPreflightContext()).required_data[0].key)
|
|
self.assertEqual("role:application-clerk", provider.apply(fragment, {}, ConfigurationPreflightContext()).created_refs["clerks"])
|
|
|
|
package = {
|
|
"package_id": "govoplan.test",
|
|
"name": "Test package",
|
|
"version": "1.0.0",
|
|
"required_modules": [{"module_id": "access", "version": "1.0.0"}],
|
|
"required_capabilities": [CONFIGURATION_PROVIDER_CAPABILITY, "access.configuration"],
|
|
"data_requirements": [{"key": "service_name", "label": "Service name"}],
|
|
"fragments": [{"module_id": "access", "fragment_type": "access_roles", "fragment_id": "clerks", "payload": {"access_roles": []}}],
|
|
}
|
|
missing_context = ConfigurationPreflightContext(
|
|
installed_modules={"access": "1.0.0"},
|
|
capabilities=frozenset({CONFIGURATION_PROVIDER_CAPABILITY, "access.configuration"}),
|
|
)
|
|
missing = dry_run_configuration_package(package, [provider], missing_context)
|
|
self.assertIn("required_data_missing", {item.code for item in missing.diagnostics})
|
|
self.assertEqual("service_name", missing.required_data[0].key)
|
|
|
|
ready_context = ConfigurationPreflightContext(
|
|
supplied_data={"service_name": "Application handling"},
|
|
installed_modules={"access": "1.0.0"},
|
|
capabilities=frozenset({CONFIGURATION_PROVIDER_CAPABILITY, "access.configuration"}),
|
|
)
|
|
dry_run = dry_run_configuration_package(package, [provider], ready_context)
|
|
self.assertFalse([item for item in dry_run.diagnostics if item.severity == "blocker"])
|
|
self.assertEqual("create", dry_run.plan[0].action)
|
|
|
|
applied = apply_configuration_package(package, [provider], ready_context)
|
|
self.assertEqual("role:application-clerk", applied.created_refs["clerks"])
|
|
|
|
exported = export_configuration_package([provider], ConfigurationExportSelection(module_ids=("access",)), ready_context)
|
|
self.assertEqual("access_roles", exported.fragments[0].fragment_type)
|
|
|
|
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_license_issuer_writes_signed_license(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-license-issuer-", dir=_TEST_ROOT))
|
|
private_key_path = root / "license-private.pem"
|
|
license_path = root / "license.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,
|
|
)
|
|
|
|
issue_module_license(
|
|
path=license_path,
|
|
license_id="license-2026",
|
|
subject="GovOPlaN Test",
|
|
features=("module.mail", "support.standard"),
|
|
valid_from="2026-01-01T00:00:00Z",
|
|
valid_until="2030-12-31T23:59:59Z",
|
|
signing_key_id="license-key-1",
|
|
signing_private_key_path=private_key_path,
|
|
issuer="GovOPlaN Release",
|
|
)
|
|
trusted_keys = {"license-key-1": base64.b64encode(public_key).decode("ascii")}
|
|
|
|
validation = validate_module_license(license_path, trusted_keys=trusted_keys, require_trusted=True)
|
|
diagnostics = module_license_diagnostics(
|
|
license_path,
|
|
trusted_keys=trusted_keys,
|
|
require_trusted=True,
|
|
required_features=("module.mail",),
|
|
)
|
|
|
|
self.assertTrue(validation["valid"], validation["error"])
|
|
self.assertEqual("license-2026", validation["license_id"])
|
|
self.assertTrue(diagnostics["trusted"])
|
|
self.assertEqual([], diagnostics["missing_features"])
|
|
self.assertIn("module.mail", diagnostics["features"])
|
|
|
|
def test_module_license_diagnostics_report_expired_unsigned_missing_and_missing_feature(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-license-diagnostics-", dir=_TEST_ROOT))
|
|
missing_path = root / "missing.json"
|
|
expired_path = root / "expired.json"
|
|
unsigned_path = root / "unsigned.json"
|
|
feature_path = root / "feature.json"
|
|
private_key_path = root / "license-private.pem"
|
|
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,
|
|
)
|
|
trusted_keys = {"license-key-1": base64.b64encode(public_key).decode("ascii")}
|
|
expired_path.write_text(json.dumps({
|
|
"license_id": "expired",
|
|
"subject": "Expired",
|
|
"features": ["module.mail"],
|
|
"valid_from": "2020-01-01T00:00:00Z",
|
|
"valid_until": "2020-01-02T00:00:00Z",
|
|
}), encoding="utf-8")
|
|
unsigned_path.write_text(json.dumps({
|
|
"license_id": "unsigned",
|
|
"subject": "Unsigned",
|
|
"features": ["module.mail"],
|
|
"valid_from": "2026-01-01T00:00:00Z",
|
|
"valid_until": "2030-12-31T23:59:59Z",
|
|
}), encoding="utf-8")
|
|
issue_module_license(
|
|
path=feature_path,
|
|
license_id="feature",
|
|
subject="Feature",
|
|
features=("module.files",),
|
|
valid_from="2026-01-01T00:00:00Z",
|
|
valid_until="2030-12-31T23:59:59Z",
|
|
signing_key_id="license-key-1",
|
|
signing_private_key_path=private_key_path,
|
|
)
|
|
|
|
with patch.dict(os.environ, {"GOVOPLAN_LICENSE_ENFORCEMENT": "true"}):
|
|
missing = module_license_diagnostics(missing_path, required_features=("module.mail",), require_trusted=True)
|
|
expired = module_license_diagnostics(expired_path, required_features=("module.mail",), require_trusted=False)
|
|
unsigned = module_license_diagnostics(unsigned_path, required_features=("module.mail",), require_trusted=True)
|
|
missing_feature = module_license_diagnostics(
|
|
feature_path,
|
|
trusted_keys=trusted_keys,
|
|
require_trusted=True,
|
|
required_features=("module.mail",),
|
|
)
|
|
|
|
self.assertFalse(missing["valid"])
|
|
self.assertFalse(missing["allowed"])
|
|
self.assertFalse(expired["valid"])
|
|
self.assertIn("expired", str(expired["reason"]))
|
|
self.assertFalse(unsigned["valid"])
|
|
self.assertFalse(unsigned["signed"])
|
|
self.assertFalse(unsigned["trusted"])
|
|
self.assertTrue(missing_feature["valid"], missing_feature["reason"])
|
|
self.assertTrue(missing_feature["trusted"])
|
|
self.assertEqual(["module.mail"], missing_feature["missing_features"])
|
|
self.assertFalse(missing_feature["allowed"])
|
|
|
|
def test_module_installer_cli_issues_and_validates_license(self) -> None:
|
|
root = Path(tempfile.mkdtemp(prefix="govoplan-license-cli-", dir=_TEST_ROOT))
|
|
private_key_path = root / "license-private.pem"
|
|
license_path = root / "license.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,
|
|
)
|
|
trusted_key = base64.b64encode(public_key).decode("ascii")
|
|
|
|
issue_result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"govoplan_core.commands.module_installer",
|
|
"--issue-license",
|
|
str(license_path),
|
|
"--license-id",
|
|
"cli-license",
|
|
"--license-subject",
|
|
"CLI Test",
|
|
"--license-feature",
|
|
"module.mail",
|
|
"--license-valid-from",
|
|
"2026-01-01T00:00:00Z",
|
|
"--license-valid-until",
|
|
"2030-12-31T23:59:59Z",
|
|
"--license-signing-key-id",
|
|
"license-key-1",
|
|
"--license-signing-private-key",
|
|
str(private_key_path),
|
|
"--format",
|
|
"json",
|
|
],
|
|
cwd=str(Path(__file__).resolve().parents[1]),
|
|
env=os.environ.copy(),
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(0, issue_result.returncode, issue_result.stderr)
|
|
self.assertTrue(json.loads(issue_result.stdout)["issued"])
|
|
|
|
validate_result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"govoplan_core.commands.module_installer",
|
|
"--validate-license",
|
|
str(license_path),
|
|
"--license-trusted-key",
|
|
f"license-key-1={trusted_key}",
|
|
"--require-trusted-license",
|
|
"--license-required-feature",
|
|
"module.mail",
|
|
"--format",
|
|
"json",
|
|
],
|
|
cwd=str(Path(__file__).resolve().parents[1]),
|
|
env=os.environ.copy(),
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(0, validate_result.returncode, validate_result.stderr)
|
|
diagnostics = json.loads(validate_result.stdout)
|
|
self.assertTrue(diagnostics["valid"], diagnostics["reason"])
|
|
self.assertTrue(diagnostics["trusted"])
|
|
self.assertEqual([], diagnostics["missing_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({"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({"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({"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({"access"}, {item["id"] for item in response.json()["modules"]})
|
|
self.assertFalse("/api/v1/files" in _route_paths(app))
|
|
|
|
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({"access", "files"}, {item["id"] for item in response.json()["modules"]})
|
|
self.assertTrue("/api/v1/files" in _route_paths(app))
|
|
self.assertEqual(401, client.get("/api/v1/files").status_code)
|
|
|
|
result = lifecycle.apply_enabled_modules((), migrate=False)
|
|
self.assertEqual(("files",), result.deactivated_modules)
|
|
response = client.get("/api/v1/platform/modules")
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
|
|
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_tenancy", "govoplan_files", "govoplan_mail", "govoplan_campaign")),
|
|
(("files",), ("govoplan_tenancy", "govoplan_mail", "govoplan_campaign")),
|
|
(("mail",), ("govoplan_tenancy", "govoplan_files", "govoplan_campaign")),
|
|
(("campaigns",), ("govoplan_tenancy", "govoplan_files", "govoplan_mail")),
|
|
(("campaigns", "files"), ("govoplan_tenancy", "govoplan_mail")),
|
|
(("campaigns", "mail"), ("govoplan_tenancy", "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(1, len(core_plan.script_locations))
|
|
self.assertTrue(core_plan.script_locations[0].endswith("govoplan_access/backend/migrations/versions"))
|
|
self.assertEqual(1, len(core_plan.metadata))
|
|
self.assertEqual(("access",), tuple(item.module_id for item in core_plan.modules))
|
|
|
|
files_plan = migration_metadata_plan(build_platform_registry(("files",)))
|
|
self.assertEqual(2, len(files_plan.script_locations))
|
|
self.assertIn("files", {item.module_id for item in files_plan.modules})
|
|
files_locations = "\n".join(files_plan.script_locations)
|
|
self.assertIn("govoplan_access/backend/migrations/versions", files_locations)
|
|
self.assertIn("govoplan_files/backend/migrations/versions", files_locations)
|
|
|
|
full_plan = migration_metadata_plan(build_platform_registry(("campaigns", "files", "mail")))
|
|
locations = "\n".join(full_plan.script_locations)
|
|
self.assertIn("govoplan_access/backend/migrations/versions", 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()
|