Harden module update compatibility cleanup
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m46s
Module Matrix / module-matrix (push) Failing after 2m38s
Release Integration / release-integration (push) Failing after 1m41s

This commit is contained in:
2026-07-10 23:27:48 +02:00
parent 2b0cdf13f3
commit b788afcae1
16 changed files with 1442 additions and 886 deletions

View File

@@ -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:

View File

@@ -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"])

View File

@@ -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"])