Prepare GovOPlaN self-hosted release workflow
This commit is contained in:
@@ -57,6 +57,7 @@ from govoplan_core.core.module_installer import (
|
||||
list_module_installer_requests,
|
||||
module_installer_daemon_status,
|
||||
module_install_preflight,
|
||||
module_manifest_compatibility_issues,
|
||||
module_installer_lock_status,
|
||||
queue_module_installer_request,
|
||||
read_module_installer_request,
|
||||
@@ -96,7 +97,7 @@ from govoplan_core.core.module_package_catalog import (
|
||||
)
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleUninstallGuardResult
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
||||
from govoplan_core.core.modules import MigrationSpec, ModuleManifest
|
||||
from govoplan_core.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
|
||||
@@ -108,7 +109,7 @@ 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 create_scope_tables
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend, StorageBackendError
|
||||
|
||||
_MANIFEST_PROJECT_MODULES = {
|
||||
@@ -557,6 +558,14 @@ finally:
|
||||
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,
|
||||
@@ -572,11 +581,69 @@ finally:
|
||||
updated = client.patch(
|
||||
f"/api/v1/admin/tenants/{tenant_id}",
|
||||
headers=headers,
|
||||
json={"name": f"Lifecycle {name} Updated", "is_active": False},
|
||||
json={"name": f"Lifecycle {name} Updated"},
|
||||
)
|
||||
self.assertEqual(200, updated.status_code, updated.text)
|
||||
self.assertEqual(f"Lifecycle {name} Updated", updated.json()["name"])
|
||||
self.assertFalse(updated.json()["is_active"])
|
||||
|
||||
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()
|
||||
@@ -590,6 +657,68 @@ finally:
|
||||
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()
|
||||
|
||||
@@ -630,11 +759,79 @@ finally:
|
||||
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_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_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)
|
||||
@@ -768,6 +965,166 @@ finally:
|
||||
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_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)
|
||||
@@ -1379,6 +1736,8 @@ finally:
|
||||
"version": "0.1.4",
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": "govoplan-files==0.1.4",
|
||||
"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",
|
||||
@@ -1396,12 +1755,48 @@ finally:
|
||||
self.assertEqual("files", catalog[0]["module_id"])
|
||||
self.assertEqual("install", catalog[0]["action"])
|
||||
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"
|
||||
@@ -1412,6 +1807,54 @@ finally:
|
||||
self.assertFalse(validation["valid"])
|
||||
self.assertIn("module_id", 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"
|
||||
@@ -1454,6 +1897,47 @@ finally:
|
||||
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.6",
|
||||
"--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.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("0.1.6", modules["files"]["version"])
|
||||
self.assertIn("@v0.1.6", 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"
|
||||
|
||||
Reference in New Issue
Block a user