Harden module update compatibility cleanup
This commit is contained in:
@@ -76,7 +76,8 @@ from govoplan_core.core.campaigns import (
|
||||
CampaignRetentionProvider,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef, OrganizationUnitRef as CoreOrganizationUnitRef
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -85,6 +86,7 @@ from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ExternalFunctionRoleAssignment,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionRoleAssignment,
|
||||
@@ -1031,6 +1033,207 @@ class AccessContractTests(unittest.TestCase):
|
||||
clear_runtime()
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
def test_access_explanation_includes_idm_function_role_sources(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-explanation-"))
|
||||
try:
|
||||
tenant_id = "tenant-access-explanation"
|
||||
account_id = "account-access-explanation"
|
||||
user_id = "user-access-explanation"
|
||||
role_id = "role-access-explanation"
|
||||
function_id = "function-access-explanation"
|
||||
organization_unit_id = "unit-access-explanation"
|
||||
assignment_id = "assignment-access-explanation"
|
||||
identity_id = "identity-access-explanation"
|
||||
|
||||
assignment = OrganizationFunctionAssignmentRef(
|
||||
id=assignment_id,
|
||||
tenant_id=tenant_id,
|
||||
identity_id=identity_id,
|
||||
account_id=account_id,
|
||||
function_id=function_id,
|
||||
organization_unit_id=organization_unit_id,
|
||||
applies_to_subunits=True,
|
||||
source="direct",
|
||||
)
|
||||
|
||||
class FakeIdmDirectory:
|
||||
def get_organization_function_assignment(self, requested_assignment_id: str):
|
||||
return assignment if requested_assignment_id == assignment_id else None
|
||||
|
||||
def organization_function_assignments_for_identity(self, requested_identity_id: str, *, tenant_id: str | None = None):
|
||||
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
|
||||
return (assignment,)
|
||||
return ()
|
||||
|
||||
def organization_function_assignments_for_account(self, requested_account_id: str, *, tenant_id: str | None = None):
|
||||
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
|
||||
return (assignment,)
|
||||
return ()
|
||||
|
||||
class FakeOrganizationDirectory(CoreOrganizationDirectory):
|
||||
def get_organization_unit(self, requested_organization_unit_id: str):
|
||||
if requested_organization_unit_id != organization_unit_id:
|
||||
return None
|
||||
return CoreOrganizationUnitRef(
|
||||
id=organization_unit_id,
|
||||
tenant_id=tenant_id,
|
||||
slug="registry",
|
||||
name="Registry Office",
|
||||
)
|
||||
|
||||
def organization_units_for_tenant(self, requested_tenant_id: str):
|
||||
if requested_tenant_id != tenant_id:
|
||||
return ()
|
||||
unit = self.get_organization_unit(organization_unit_id)
|
||||
return (unit,) if unit is not None else ()
|
||||
|
||||
def get_function(self, requested_function_id: str):
|
||||
if requested_function_id != function_id:
|
||||
return None
|
||||
return CoreOrganizationFunctionRef(
|
||||
id=function_id,
|
||||
tenant_id=tenant_id,
|
||||
organization_unit_id=organization_unit_id,
|
||||
slug="registry-clerk",
|
||||
name="Registry Clerk",
|
||||
)
|
||||
|
||||
def functions_for_organization_unit(self, requested_organization_unit_id: str, *, include_subunits: bool = False):
|
||||
del include_subunits
|
||||
if requested_organization_unit_id != organization_unit_id:
|
||||
return ()
|
||||
function = self.get_function(function_id)
|
||||
return (function,) if function is not None else ()
|
||||
|
||||
idm_directory = FakeIdmDirectory()
|
||||
organization_directory = FakeOrganizationDirectory()
|
||||
|
||||
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
||||
create_scope_tables(database.engine)
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id=tenant_id, slug="access-explanation", name="Access Explanation", settings={})
|
||||
account = Account(
|
||||
id=account_id,
|
||||
email="explanation@example.test",
|
||||
normalized_email="explanation@example.test",
|
||||
display_name="Access Explanation User",
|
||||
)
|
||||
user = User(
|
||||
id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
settings={},
|
||||
mail_profile_policy={},
|
||||
)
|
||||
role = Role(
|
||||
id=role_id,
|
||||
tenant_id=tenant_id,
|
||||
slug="file-reader",
|
||||
name="File Reader",
|
||||
permissions=["files:file:read"],
|
||||
)
|
||||
mapping = ExternalFunctionRoleAssignment(
|
||||
tenant_id=tenant_id,
|
||||
source_module="organizations",
|
||||
function_id=function_id,
|
||||
role_id=role_id,
|
||||
)
|
||||
session.add_all([tenant, account, user, role, mapping])
|
||||
session.commit()
|
||||
|
||||
from govoplan_access.backend.explanation import SqlAccessExplanationService, build_user_access_explanation
|
||||
|
||||
explanation = build_user_access_explanation(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(explanation.function_facts))
|
||||
self.assertEqual("Registry Clerk", explanation.function_facts[0].function_name)
|
||||
self.assertEqual((role_id,), explanation.function_facts[0].role_ids)
|
||||
source = next(item for item in explanation.role_sources if item.source_type == "idm_function_role")
|
||||
self.assertEqual("Registry Clerk", source.function_name)
|
||||
self.assertEqual("File Reader", source.role_name)
|
||||
scope = next(item for item in explanation.scopes if item.scope == "files:file:read")
|
||||
self.assertEqual(["idm_function_role"], [item.source_type for item in scope.sources])
|
||||
|
||||
service = SqlAccessExplanationService(
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
provenance = service.explain_scope_provenance(
|
||||
PrincipalRef(
|
||||
account_id=account_id,
|
||||
membership_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
identity_id=identity_id,
|
||||
scopes=frozenset({"files:file:read"}),
|
||||
),
|
||||
"files:file:read",
|
||||
)
|
||||
self.assertTrue(any(item.kind == "function" and item.label == "Registry Clerk" for item in provenance))
|
||||
self.assertTrue(any(item.kind == "role" and item.label == "File Reader" for item in provenance))
|
||||
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="idm",
|
||||
name="IDM",
|
||||
version="test",
|
||||
capability_factories={CAPABILITY_IDM_DIRECTORY: lambda context: idm_directory},
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="organizations",
|
||||
name="Organizations",
|
||||
version="test",
|
||||
capability_factories={CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory},
|
||||
)
|
||||
)
|
||||
context = ModuleContext(registry=registry, settings=object())
|
||||
registry.configure_capability_context(context)
|
||||
configure_runtime(context)
|
||||
|
||||
from govoplan_access.backend.api.v1.routes import router as admin_router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(admin_router)
|
||||
|
||||
def principal_override() -> ApiPrincipal:
|
||||
with database.session() as session:
|
||||
account = session.get(Account, account_id)
|
||||
user = session.get(User, user_id)
|
||||
assert account is not None
|
||||
assert user is not None
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id=account_id,
|
||||
membership_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
scopes=frozenset({"admin:users:read"}),
|
||||
),
|
||||
account=account,
|
||||
user=user,
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_api_principal] = principal_override
|
||||
with TestClient(app) as client:
|
||||
response = client.get(f"/admin/users/{user_id}/access-explanation")
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
payload = response.json()
|
||||
self.assertIn("files:file:read", payload["user"]["effective_scopes"])
|
||||
self.assertEqual("idm_function_role", payload["role_sources"][0]["source_type"])
|
||||
self.assertEqual("Registry Clerk", payload["function_facts"][0]["function_name"])
|
||||
finally:
|
||||
clear_runtime()
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
|
||||
try:
|
||||
|
||||
@@ -33,13 +33,14 @@ from govoplan_core.db.bootstrap import bootstrap_dev_data
|
||||
from govoplan_core.db.session import configure_database, set_database
|
||||
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
|
||||
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
|
||||
from govoplan_core.tenancy.scope import create_scope_tables, scope_registry
|
||||
from govoplan_access.backend.permissions.catalog import permission_catalog as access_permission_catalog
|
||||
|
||||
_database = configure_database(os.environ["DATABASE_URL"])
|
||||
engine = _database.engine
|
||||
SessionLocal = _database.SessionLocal
|
||||
|
||||
from govoplan_core.server.app import app
|
||||
from govoplan_core.security.permissions import SYSTEM_SCOPES
|
||||
|
||||
|
||||
class ApiSmokeTests(unittest.TestCase):
|
||||
@@ -56,7 +57,9 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
set_database(_database)
|
||||
self.client.cookies.clear()
|
||||
scope_registry.metadata.drop_all(bind=engine)
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
create_scope_tables(engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
|
||||
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
|
||||
@@ -5972,7 +5975,12 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
auditor = next(item for item in roles.json()["roles"] if item["slug"] == "system_auditor")
|
||||
self.assertTrue(owner["is_builtin"])
|
||||
self.assertEqual(owner["user_assignments"], 1)
|
||||
self.assertEqual(owner["effective_permission_count"], len(SYSTEM_SCOPES))
|
||||
expected_system_permission_count = sum(
|
||||
1
|
||||
for permission in access_permission_catalog(include_legacy=False)
|
||||
if permission.level == "system"
|
||||
)
|
||||
self.assertEqual(owner["effective_permission_count"], expected_system_permission_count)
|
||||
self.assertEqual(owner["permissions"], ["system:*"])
|
||||
self.assertFalse(administrator["is_builtin"])
|
||||
self.assertFalse(auditor["is_builtin"])
|
||||
|
||||
@@ -265,6 +265,18 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
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
|
||||
@@ -784,6 +796,26 @@ finally:
|
||||
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()
|
||||
@@ -832,6 +864,50 @@ finally:
|
||||
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)
|
||||
@@ -1097,6 +1173,345 @@ finally:
|
||||
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_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.",
|
||||
}],
|
||||
},
|
||||
):
|
||||
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_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.",
|
||||
}],
|
||||
},
|
||||
):
|
||||
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].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_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.",
|
||||
}],
|
||||
},
|
||||
):
|
||||
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_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(
|
||||
@@ -1736,6 +2151,10 @@ finally:
|
||||
"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.",
|
||||
"provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}],
|
||||
"requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}],
|
||||
"artifact_integrity": {
|
||||
@@ -1754,6 +2173,10 @@ finally:
|
||||
|
||||
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(["official"], catalog[0]["tags"])
|
||||
self.assertEqual([{"name": "files.spaces", "version": "1.2.0"}], catalog[0]["provides_interfaces"])
|
||||
self.assertEqual(
|
||||
@@ -1807,6 +2230,21 @@ finally:
|
||||
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_interface_ranges(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-interface-", dir=_TEST_ROOT))
|
||||
catalog_path = root / "catalog.json"
|
||||
@@ -1928,6 +2366,7 @@ finally:
|
||||
"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",
|
||||
@@ -1935,6 +2374,9 @@ finally:
|
||||
"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.6", modules["files"]["version"])
|
||||
self.assertIn("@v0.1.6", modules["files"]["python_ref"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user