Harden module update compatibility cleanup
This commit is contained in:
@@ -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