Prepare GovOPlaN self-hosted release workflow
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 50s
Module Matrix / module-matrix (push) Failing after 49s

This commit is contained in:
2026-07-10 21:57:22 +02:00
parent 8dd5123aab
commit 94236a7d7e
51 changed files with 3576 additions and 803 deletions

View File

@@ -22,6 +22,8 @@ from govoplan_core.core.access import (
CAPABILITY_ACCESS_EXPLANATION,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUDIT_SINK,
CAPABILITY_TENANCY_TENANT_RESOLVER,
AccessDecision,
@@ -34,7 +36,12 @@ from govoplan_core.core.access import (
AccessSubjectRef,
AccountRef,
AuditEvent,
AuditRecordRef,
AuditRecorder,
AuditRetentionProvider,
AuditSink,
CreatedApiKeyRef,
DevelopmentBootstrapRef,
FunctionAssignmentRef,
FunctionDelegationRef,
FunctionRef,
@@ -69,7 +76,9 @@ 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.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base
from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig
@@ -265,8 +274,19 @@ class _FakeTenantAccessProvisioner:
del session, tenant, owner_account_id
return "user-1"
def ensure_development_admin(self, session: object, *, tenant: object, user_email: str, user_password: str, api_key_secret: str | None, scopes):
del session, tenant, user_password, scopes
return DevelopmentBootstrapRef(
user=UserRef(id="user-1", account_id="account-1", tenant_id="tenant-1", email=user_email),
created_api_key=CreatedApiKeyRef(id="api-key-1", secret=api_key_secret) if api_key_secret else None,
)
class _FakeAccessAdministration:
def tenant_counts(self, session: object, tenant_id: str):
del session, tenant_id
return {"users": 1, "active_users": 1, "groups": 2, "api_keys": 3}
def system_account_count(self, session: object) -> int:
del session
return 1
@@ -287,6 +307,22 @@ class _FakeAccessAdministration:
del session, operator, value
return ("user-1",)
def user_settings(self, session: object, user_id: str, *, tenant_id: str):
del session, user_id, tenant_id
return {"privacy_retention_policy": {"audit_detail_level": "minimal"}}
def set_user_settings(self, session: object, user_id: str, *, tenant_id: str, settings):
del session, user_id, tenant_id
return dict(settings)
def group_settings(self, session: object, group_id: str, *, tenant_id: str):
del session, group_id, tenant_id
return {"privacy_retention_policy": {"audit_detail_level": "redacted"}}
def set_group_settings(self, session: object, group_id: str, *, tenant_id: str, settings):
del session, group_id, tenant_id
return dict(settings)
class _FakeAccessGovernanceMaterializer:
def __init__(self) -> None:
@@ -408,6 +444,28 @@ class _FakeAuditSink:
self.events.append(event)
class _FakeAuditRecorder:
def record_event(self, session: object, event: AuditEvent) -> AuditRecordRef:
del session
return AuditRecordRef(
id="audit-1",
scope=event.scope,
tenant_id=event.tenant_id,
user_id=event.user_id,
api_key_id=event.api_key_id,
action=event.action,
object_type=event.resource_type,
object_id=event.resource_id,
details=event.details,
)
class _FakeAuditRetentionProvider:
def apply_detail_retention(self, session: object, *, dry_run: bool, now, policy_for_record):
del session, dry_run, now, policy_for_record
return {"eligible": 1, "redacted": 0, "already_redacted": 0}
class AccessContractTests(unittest.TestCase):
def test_access_capability_names_are_stable(self) -> None:
self.assertEqual("access", ACCESS_MODULE_ID)
@@ -428,6 +486,8 @@ class AccessContractTests(unittest.TestCase):
self.assertEqual("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER)
self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER)
self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK)
self.assertEqual("audit.recorder", CAPABILITY_AUDIT_RECORDER)
self.assertEqual("audit.retention", CAPABILITY_AUDIT_RETENTION)
self.assertEqual(len(ACCESS_CAPABILITY_NAMES), len(set(ACCESS_CAPABILITY_NAMES)))
def test_access_refs_are_small_immutable_dtos(self) -> None:
@@ -533,6 +593,8 @@ class AccessContractTests(unittest.TestCase):
self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider)
self.assertIsInstance(_FakeSecretProvider(), SecretProvider)
self.assertIsInstance(_FakeAuditSink(), AuditSink)
self.assertIsInstance(_FakeAuditRecorder(), AuditRecorder)
self.assertIsInstance(_FakeAuditRetentionProvider(), AuditRetentionProvider)
def test_campaign_mail_policy_context_capability_shape(self) -> None:
provider = _FakeCampaignMailPolicyContextProvider()
@@ -843,6 +905,132 @@ class AccessContractTests(unittest.TestCase):
finally:
shutil.rmtree(root, ignore_errors=True)
def test_external_function_role_mappings_require_organizations_directory(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-external-function-map-"))
try:
tenant_id = "tenant-external-function-map"
account_id = "account-external-function-map"
user_id = "user-external-function-map"
role_id = "role-external-function-map"
function_id = "function-external-function-map"
with temporary_database(f"sqlite:///{root / 'external-function-map.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="external-function-map", name="External Function Map", settings={})
account = Account(
id=account_id,
email="external-function-map@example.test",
normalized_email="external-function-map@example.test",
display_name="External Function Map Admin",
)
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="external-function-reader",
name="External Function Reader",
permissions=["files:file:read"],
is_assignable=True,
)
session.add_all([tenant, account, user, role])
session.commit()
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:roles:read", "admin:roles:write", "access:role:assign"}),
),
account=account,
user=user,
)
app.dependency_overrides[get_api_principal] = principal_override
clear_runtime()
with TestClient(app) as client:
blocked_response = client.post(
"/admin/external-function-role-mappings",
json={"source_module": "organizations", "function_id": function_id, "role_id": role_id},
)
self.assertEqual(409, blocked_response.status_code, blocked_response.text)
self.assertEqual("organizations_required", blocked_response.json()["detail"]["code"])
class FakeOrganizationDirectory(CoreOrganizationDirectory):
def get_organization_unit(self, organization_unit_id: str):
del organization_unit_id
return None
def organization_units_for_tenant(self, tenant_id: str):
del tenant_id
return ()
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="unit-external-function-map",
slug="external-function",
name="External Function",
)
def functions_for_organization_unit(self, organization_unit_id: str, *, include_subunits: bool = False):
del organization_unit_id, include_subunits
return ()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="organizations",
name="Organizations",
version="test",
capability_factories={CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: FakeOrganizationDirectory()},
)
)
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
created_response = client.post(
"/admin/external-function-role-mappings",
json={"source_module": "organizations", "function_id": function_id, "role_id": role_id},
)
self.assertEqual(201, created_response.status_code, created_response.text)
self.assertEqual(function_id, created_response.json()["function_id"])
unsupported_response = client.post(
"/admin/external-function-role-mappings",
json={"source_module": "legacy", "function_id": function_id, "role_id": role_id},
)
self.assertEqual(422, unsupported_response.status_code, unsupported_response.text)
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

@@ -20,12 +20,22 @@ from govoplan_core.core.events import (
event_context,
normalize_trace_id,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.registry import build_platform_registry
from tests.db_isolation import temporary_database
def _configure_audit_runtime() -> None:
registry = build_platform_registry(("audit",))
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
class CoreEventTests(unittest.TestCase):
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
bus = EventBus()
@@ -127,6 +137,7 @@ class CoreEventTests(unittest.TestCase):
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
try:
with temporary_database(f"sqlite:///{root / 'audit.db'}") as database:
_configure_audit_runtime()
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_admin.backend.db import models as admin_models # noqa: F401
from govoplan_audit.backend.db import models as audit_models # noqa: F401
@@ -148,12 +159,14 @@ class CoreEventTests(unittest.TestCase):
self.assertEqual({"correlation_id": "corr-1", "causation_id": "cause-1"}, item.details["_trace"])
self.assertEqual("<redacted>", item.details["password"])
finally:
clear_runtime()
shutil.rmtree(root, ignore_errors=True)
def test_audit_event_publishes_governed_platform_event(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-event-"))
try:
with temporary_database(f"sqlite:///{root / 'audit.db'}") as database:
_configure_audit_runtime()
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_admin.backend.db import models as admin_models # noqa: F401
from govoplan_audit.backend.db import models as audit_models # noqa: F401
@@ -187,12 +200,14 @@ class CoreEventTests(unittest.TestCase):
self.assertEqual(item.id, event.payload["audit_log_id"])
self.assertEqual("<redacted>", event.payload["details"]["password"])
finally:
clear_runtime()
shutil.rmtree(root, ignore_errors=True)
def test_audit_events_map_existing_module_action_prefixes(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-module-events-"))
try:
with temporary_database(f"sqlite:///{root / 'audit.db'}") as database:
_configure_audit_runtime()
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_admin.backend.db import models as admin_models # noqa: F401
from govoplan_audit.backend.db import models as audit_models # noqa: F401
@@ -225,6 +240,7 @@ class CoreEventTests(unittest.TestCase):
by_type = {event.type: event.module_id for event in seen if event.type in cases}
self.assertEqual(cases, by_type)
finally:
clear_runtime()
shutil.rmtree(root, ignore_errors=True)
def test_audit_operation_context_keeps_lifecycle_ids_and_sanitizes_details(self) -> None:

View File

@@ -11,21 +11,26 @@ from govoplan_core.core.identity import (
IdentityDirectory,
IdentityRef,
)
from govoplan_core.core.idm import (
CAPABILITY_IDM_DIRECTORY,
IdmDirectory,
OrganizationFunctionAssignmentRef,
)
from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY,
OrganizationDirectory,
OrganizationFunctionAssignmentRef,
OrganizationFunctionRef,
OrganizationUnitRef,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.base import Base
from govoplan_access.backend.db.models import User # noqa: F401 - registers legacy tenancy relationship target
from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment, Role, User
from govoplan_access.backend.semantic import collect_external_function_roles
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
from govoplan_organizations.backend.db.models import (
OrganizationFunction,
OrganizationFunctionAssignment,
OrganizationFunctionType,
OrganizationRelation,
OrganizationRelationType,
@@ -73,15 +78,6 @@ class _FakeOrganizationDirectory:
slug="registry-clerk",
name="Registry Clerk",
)
assignment = OrganizationFunctionAssignmentRef(
id="assignment-1",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id=function.id,
organization_unit_id=unit.id,
applies_to_subunits=True,
)
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
return self.unit if organization_unit_id == self.unit.id else None
@@ -96,17 +92,29 @@ class _FakeOrganizationDirectory:
del include_subunits
return (self.function,) if organization_unit_id == self.unit.id else ()
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
class _FakeIdmDirectory:
assignment = OrganizationFunctionAssignmentRef(
id="assignment-1",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id=_FakeOrganizationDirectory.function.id,
organization_unit_id=_FakeOrganizationDirectory.unit.id,
applies_to_subunits=True,
)
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
return self.assignment if assignment_id == self.assignment.id else None
def function_assignments_for_account(self, account_id: str, *, tenant_id: str | None = None):
def organization_function_assignments_for_account(self, account_id: str, *, tenant_id: str | None = None):
if account_id != self.assignment.account_id:
return ()
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
return ()
return (self.assignment,)
def function_assignments_for_identity(self, identity_id: str, *, tenant_id: str | None = None):
def organization_function_assignments_for_identity(self, identity_id: str, *, tenant_id: str | None = None):
if identity_id != self.assignment.identity_id:
return ()
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
@@ -118,10 +126,12 @@ class IdentityOrganizationContractTests(unittest.TestCase):
def test_protocol_shapes_match_reference_implementations(self) -> None:
self.assertIsInstance(_FakeIdentityDirectory(), IdentityDirectory)
self.assertIsInstance(_FakeOrganizationDirectory(), OrganizationDirectory)
self.assertIsInstance(_FakeIdmDirectory(), IdmDirectory)
def test_capabilities_register_and_resolve_through_platform_registry(self) -> None:
identity_directory = _FakeIdentityDirectory()
organization_directory = _FakeOrganizationDirectory()
idm_directory = _FakeIdmDirectory()
manifest = ModuleManifest(
id="directory-contract-test",
name="Directory Contract Test",
@@ -129,6 +139,7 @@ class IdentityOrganizationContractTests(unittest.TestCase):
capability_factories={
CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory,
CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory,
CAPABILITY_IDM_DIRECTORY: lambda context: idm_directory,
},
)
registry = PlatformRegistry()
@@ -137,6 +148,64 @@ class IdentityOrganizationContractTests(unittest.TestCase):
self.assertIs(identity_directory, registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY))
self.assertIs(organization_directory, registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY))
self.assertIs(idm_directory, registry.require_capability(CAPABILITY_IDM_DIRECTORY))
def test_access_directory_can_optionally_consume_idm_assignments(self) -> None:
from govoplan_access.backend.directory import SqlAccessDirectory
root = Path(tempfile.mkdtemp(prefix="govoplan-access-idm-directory-"))
try:
with temporary_database(f"sqlite:///{root / 'access-idm.db'}") as database:
Base.metadata.create_all(bind=database.engine)
directory = SqlAccessDirectory(idm_directory=_FakeIdmDirectory())
assignments = directory.function_assignments_for_account("account-1", tenant_id="tenant-1")
self.assertEqual(("assignment-1",), tuple(item.id for item in assignments))
self.assertEqual("identity-1", assignments[0].identity_id)
self.assertEqual("function-1", assignments[0].function_id)
finally:
shutil.rmtree(root, ignore_errors=True)
def test_access_maps_idm_function_facts_to_roles_explicitly(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-idm-role-map-"))
try:
with temporary_database(f"sqlite:///{root / 'access-idm-role-map.db'}") as database:
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
user = User(
id="user-idm-role-map",
tenant_id="tenant-1",
account_id="account-1",
email="role-map@example.test",
display_name="Role Map",
settings={},
mail_profile_policy={},
)
role = Role(
id="role-idm-function",
tenant_id="tenant-1",
slug="idm-function-reader",
name="IDM Function Reader",
permissions=["files:file:read"],
is_assignable=True,
)
mapping = ExternalFunctionRoleAssignment(
tenant_id="tenant-1",
source_module="organizations",
function_id="function-1",
role_id=role.id,
settings={},
)
session.add_all([user, role, mapping])
session.commit()
roles = collect_external_function_roles(
session,
user,
_FakeIdmDirectory().organization_function_assignments_for_account("account-1", tenant_id="tenant-1"),
)
self.assertEqual(["role-idm-function"], [item.id for item in roles])
finally:
shutil.rmtree(root, ignore_errors=True)
def test_sql_identity_and_organization_directories_return_dtos(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-directory-contracts-"))
@@ -210,7 +279,7 @@ class IdentityOrganizationContractTests(unittest.TestCase):
slug="registry-clerk",
name="Registry Clerk",
)
assignment = OrganizationFunctionAssignment(
assignment = IdmOrganizationFunctionAssignment(
id="assignment-directory",
tenant_id=tenant.id,
identity_id=identity.id,
@@ -235,10 +304,12 @@ class IdentityOrganizationContractTests(unittest.TestCase):
session.commit()
from govoplan_identity.backend.directory import SqlIdentityDirectory
from govoplan_idm.backend.directory import SqlIdmDirectory
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
identity_directory = SqlIdentityDirectory()
organization_directory = SqlOrganizationDirectory(identity_directory=identity_directory)
idm_directory = SqlIdmDirectory()
organization_directory = SqlOrganizationDirectory()
self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr]
self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr]
@@ -249,11 +320,11 @@ class IdentityOrganizationContractTests(unittest.TestCase):
self.assertEqual("function-type-directory", organization_directory.get_function("function-directory").function_type_id) # type: ignore[union-attr]
self.assertEqual(
["assignment-directory"],
[item.id for item in organization_directory.function_assignments_for_identity("identity-directory", tenant_id="tenant-directory")],
[item.id for item in idm_directory.organization_function_assignments_for_identity("identity-directory", tenant_id="tenant-directory")],
)
self.assertEqual(
["assignment-directory"],
[item.id for item in organization_directory.function_assignments_for_account("account-directory", tenant_id="tenant-directory")],
[item.id for item in idm_directory.organization_function_assignments_for_account("account-directory", tenant_id="tenant-directory")],
)
finally:
shutil.rmtree(root, ignore_errors=True)

403
tests/test_idm_api.py Normal file
View File

@@ -0,0 +1,403 @@
from __future__ import annotations
import shutil
import tempfile
import unittest
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from govoplan_access.backend.db.models import Account, User
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request
from govoplan_core.db.base import Base
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
from govoplan_idm.backend.api.v1.routes import router as idm_router
from govoplan_idm.backend.manifest import get_manifest
from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit
from tests.db_isolation import temporary_database
class IdmApiTests(unittest.TestCase):
def _principal(self, scopes: set[str]) -> ApiPrincipal:
account = Account(
id="account-idm-api",
email="idm-admin@example.test",
normalized_email="idm-admin@example.test",
display_name="IDM Admin",
)
user = User(
id="user-idm-api",
tenant_id="tenant-idm-api",
account_id=account.id,
email=account.email,
display_name=account.display_name,
settings={},
mail_profile_policy={},
)
return ApiPrincipal(
principal=PrincipalRef(
account_id=account.id,
membership_id=user.id,
tenant_id=user.tenant_id,
scopes=frozenset(scopes),
),
account=account,
user=user,
)
def _app(self, scopes: set[str]) -> FastAPI:
app = FastAPI()
app.include_router(idm_router, prefix="/api/v1")
app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes)
return app
def test_idm_hard_depends_on_identity_and_organizations(self) -> None:
manifest = get_manifest()
self.assertEqual(("identity", "organizations"), manifest.dependencies)
def test_organization_identity_candidates_are_idm_bridge(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-idm-api-"))
try:
with temporary_database(f"sqlite:///{root / 'idm.db'}") as database:
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
identity = Identity(
id="identity-idm-alice",
display_name="Alice Registry",
external_subject="alice@example.test",
source="local",
)
session.add(identity)
session.add(
IdentityAccountLink(
identity_id=identity.id,
account_id="account-idm-alice-primary",
is_primary=True,
source="local",
)
)
session.commit()
app = self._app({"organizations:function:assign"})
with TestClient(app) as client:
response = client.get("/api/v1/idm/organization-identities", params={"query": "registry"})
self.assertEqual(200, response.status_code, response.text)
payload = response.json()
self.assertEqual(1, len(payload["identities"]))
item = payload["identities"][0]
self.assertEqual("identity-idm-alice", item["id"])
self.assertEqual("Alice Registry", item["display_name"])
self.assertEqual("account-idm-alice-primary", item["primary_account_id"])
finally:
shutil.rmtree(root, ignore_errors=True)
def test_organization_function_assignments_are_owned_by_idm(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-idm-assignments-api-"))
try:
with temporary_database(f"sqlite:///{root / 'idm-assignments.db'}") as database:
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
identity = Identity(
id="identity-idm-bob",
display_name="Bob Registry",
external_subject="bob@example.test",
source="local",
)
unit = OrganizationUnit(
id="unit-idm-registry",
tenant_id="tenant-idm-api",
slug="registry",
name="Registry",
settings={},
)
function = OrganizationFunction(
id="function-idm-clerk",
tenant_id="tenant-idm-api",
organization_unit_id=unit.id,
slug="registry-clerk",
name="Registry Clerk",
settings={},
)
session.add_all([
identity,
IdentityAccountLink(
identity_id=identity.id,
account_id="account-idm-bob-primary",
is_primary=True,
source="local",
),
unit,
function,
])
session.commit()
app = self._app({"idm:organization_assignment:read", "idm:organization_assignment:write"})
with TestClient(app) as client:
create_response = client.post(
"/api/v1/idm/organization-function-assignments",
json={
"identity_id": "identity-idm-bob",
"account_id": "account-idm-bob-primary",
"function_id": "function-idm-clerk",
"applies_to_subunits": True,
},
)
self.assertEqual(201, create_response.status_code, create_response.text)
created = create_response.json()
self.assertEqual("identity-idm-bob", created["identity_id"])
self.assertEqual("function-idm-clerk", created["function_id"])
self.assertEqual("unit-idm-registry", created["organization_unit_id"])
self.assertTrue(created["applies_to_subunits"])
list_response = client.get("/api/v1/idm/organization-function-assignments")
self.assertEqual(200, list_response.status_code, list_response.text)
self.assertEqual([created["id"]], [item["id"] for item in list_response.json()["assignments"]])
patch_response = client.patch(
f"/api/v1/idm/organization-function-assignments/{created['id']}",
json={"is_active": False, "applies_to_subunits": False},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
patched = patch_response.json()
self.assertFalse(patched["is_active"])
self.assertFalse(patched["applies_to_subunits"])
with database.session() as session:
self.assertEqual(1, session.query(IdmOrganizationFunctionAssignment).count())
finally:
shutil.rmtree(root, ignore_errors=True)
def test_legacy_organization_assign_scope_remains_assignment_compatibility(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-idm-legacy-scope-"))
try:
with temporary_database(f"sqlite:///{root / 'idm-legacy.db'}") as database:
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
session.add(Identity(id="identity-idm-legacy", display_name="Legacy Person", source="local"))
session.add(IdentityAccountLink(identity_id="identity-idm-legacy", account_id="account-idm-legacy", is_primary=True, source="local"))
session.add(OrganizationUnit(id="unit-idm-legacy", tenant_id="tenant-idm-api", slug="legacy", name="Legacy", settings={}))
session.add(
OrganizationFunction(
id="function-idm-legacy",
tenant_id="tenant-idm-api",
organization_unit_id="unit-idm-legacy",
slug="legacy-clerk",
name="Legacy Clerk",
settings={},
)
)
session.commit()
app = self._app({"organizations:function:assign"})
with TestClient(app) as client:
create_response = client.post(
"/api/v1/idm/organization-function-assignments",
json={
"identity_id": "identity-idm-legacy",
"account_id": "account-idm-legacy",
"function_id": "function-idm-legacy",
},
)
self.assertEqual(201, create_response.status_code, create_response.text)
list_response = client.get("/api/v1/idm/organization-function-assignments")
self.assertEqual(200, list_response.status_code, list_response.text)
self.assertEqual([create_response.json()["id"]], [item["id"] for item in list_response.json()["assignments"]])
finally:
shutil.rmtree(root, ignore_errors=True)
def test_idm_assignment_change_requests_can_be_required_per_tenant(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-idm-change-control-"))
try:
with temporary_database(f"sqlite:///{root / 'idm-change-control.db'}") as database:
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
session.add(Identity(id="identity-idm-change", display_name="Change Person", source="local"))
session.add(IdentityAccountLink(identity_id="identity-idm-change", account_id="account-idm-change", is_primary=True, source="local"))
session.add(OrganizationUnit(id="unit-idm-change", tenant_id="tenant-idm-api", slug="change", name="Change", settings={}))
session.add(
OrganizationFunction(
id="function-idm-change",
tenant_id="tenant-idm-api",
organization_unit_id="unit-idm-change",
slug="change-clerk",
name="Change Clerk",
settings={},
)
)
session.add(
IdmTenantSettings(
tenant_id="tenant-idm-api",
require_assignment_change_requests=True,
audit_detail_level="standard",
settings={},
)
)
session.commit()
app = self._app({"idm:organization_assignment:write", "idm:organization_assignment:read"})
payload = {
"identity_id": "identity-idm-change",
"account_id": "account-idm-change",
"function_id": "function-idm-change",
"applies_to_subunits": True,
}
with TestClient(app) as client:
blocked = client.post("/api/v1/idm/organization-function-assignments", json=payload)
self.assertEqual(409, blocked.status_code, blocked.text)
self.assertEqual("configuration_request_required", blocked.json()["detail"]["code"])
with database.session() as session:
request = create_configuration_change_request(
session,
key="idm.organization_assignments",
value={
"resource_type": "organization_function_assignment",
"operation": "created",
"payload": payload,
},
actor_user_id="user-idm-api",
actor_scopes=("idm:organization_assignment:write",),
dry_run=True,
target={
"tenant_id": "tenant-idm-api",
"resource_type": "organization_function_assignment",
"operation": "created",
},
)
session.commit()
with TestClient(app) as client:
applied = client.post(
"/api/v1/idm/organization-function-assignments",
json={**payload, "change_request_id": request["id"]},
)
self.assertEqual(201, applied.status_code, applied.text)
self.assertEqual("identity-idm-change", applied.json()["identity_id"])
with database.session() as session:
history = configuration_control_snapshot(session)["history"]
self.assertEqual("idm.organization_assignments", history[0]["key"])
self.assertEqual("idm.organization_assignment.updated", history[0]["audit_event"])
finally:
shutil.rmtree(root, ignore_errors=True)
def test_idm_settings_can_be_read_and_updated(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-idm-settings-"))
try:
with temporary_database(f"sqlite:///{root / 'idm-settings.db'}") as database:
Base.metadata.create_all(bind=database.engine)
app = self._app({"idm:settings:read", "idm:settings:write"})
with TestClient(app) as client:
initial = client.get("/api/v1/idm/settings")
self.assertEqual(200, initial.status_code, initial.text)
self.assertFalse(initial.json()["require_assignment_change_requests"])
updated = client.patch(
"/api/v1/idm/settings",
json={
"require_assignment_change_requests": True,
"audit_detail_level": "full",
"change_retention_days": 90,
},
)
self.assertEqual(200, updated.status_code, updated.text)
payload = updated.json()
self.assertTrue(payload["require_assignment_change_requests"])
self.assertEqual("full", payload["audit_detail_level"])
self.assertEqual(90, payload["change_retention_days"])
finally:
shutil.rmtree(root, ignore_errors=True)
def test_delegated_assignments_require_delegable_function_and_source_assignment(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-idm-delegation-rules-"))
try:
with temporary_database(f"sqlite:///{root / 'idm-delegation.db'}") as database:
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
session.add_all([
Identity(id="identity-idm-source", display_name="Source Person", source="local"),
IdentityAccountLink(identity_id="identity-idm-source", account_id="account-idm-source", is_primary=True, source="local"),
Identity(id="identity-idm-delegate", display_name="Delegate Person", source="local"),
IdentityAccountLink(identity_id="identity-idm-delegate", account_id="account-idm-delegate", is_primary=True, source="local"),
OrganizationUnit(id="unit-idm-delegation", tenant_id="tenant-idm-api", slug="delegation", name="Delegation", settings={}),
OrganizationFunction(
id="function-idm-not-delegable",
tenant_id="tenant-idm-api",
organization_unit_id="unit-idm-delegation",
slug="not-delegable",
name="Not Delegable",
delegable=False,
settings={},
),
OrganizationFunction(
id="function-idm-delegable",
tenant_id="tenant-idm-api",
organization_unit_id="unit-idm-delegation",
slug="delegable",
name="Delegable",
delegable=True,
settings={},
),
])
session.add(
IdmOrganizationFunctionAssignment(
id="assignment-idm-source",
tenant_id="tenant-idm-api",
identity_id="identity-idm-source",
account_id="account-idm-source",
function_id="function-idm-delegable",
organization_unit_id="unit-idm-delegation",
applies_to_subunits=False,
source="direct",
settings={},
)
)
session.commit()
app = self._app({"idm:organization_assignment:read", "idm:organization_assignment:write"})
with TestClient(app) as client:
missing_source = client.post(
"/api/v1/idm/organization-function-assignments",
json={
"identity_id": "identity-idm-delegate",
"account_id": "account-idm-delegate",
"function_id": "function-idm-delegable",
"source": "delegated",
},
)
self.assertEqual(422, missing_source.status_code, missing_source.text)
not_delegable = client.post(
"/api/v1/idm/organization-function-assignments",
json={
"identity_id": "identity-idm-delegate",
"account_id": "account-idm-delegate",
"function_id": "function-idm-not-delegable",
"source": "delegated",
"delegated_from_assignment_id": "assignment-idm-source",
},
)
self.assertEqual(422, not_delegable.status_code, not_delegable.text)
delegated = client.post(
"/api/v1/idm/organization-function-assignments",
json={
"identity_id": "identity-idm-delegate",
"account_id": "account-idm-delegate",
"function_id": "function-idm-delegable",
"source": "delegated",
"delegated_from_assignment_id": "assignment-idm-source",
},
)
self.assertEqual(201, delegated.status_code, delegated.text)
self.assertEqual("delegated", delegated.json()["source"])
finally:
shutil.rmtree(root, ignore_errors=True)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
import unittest
from cryptography.fernet import Fernet
from govoplan_core.core.install_config import env_template, validate_runtime_configuration
class InstallConfigTests(unittest.TestCase):
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
result = validate_runtime_configuration({}, profile="self-hosted")
self.assertFalse(result.ok)
errors = {issue.key: issue for issue in result.errors}
self.assertIn("APP_ENV", errors)
self.assertIn("DATABASE_URL", errors)
self.assertIn("MASTER_KEY_B64", errors)
self.assertIn("ENABLED_MODULES", errors)
self.assertIn("CORS_ORIGINS", errors)
self.assertIn("Set DATABASE_URL", errors["DATABASE_URL"].action)
def test_self_hosted_validation_accepts_explicit_postgres_configuration(self) -> None:
result = validate_runtime_configuration(
{
"APP_ENV": "production",
"GOVOPLAN_INSTALL_PROFILE": "self-hosted",
"DATABASE_URL": "postgresql+psycopg://govoplan:secret@db.example.internal:5432/govoplan",
"GOVOPLAN_DATABASE_URL_PGTOOLS": "postgresql://govoplan:secret@db.example.internal:5432/govoplan",
"MASTER_KEY_B64": Fernet.generate_key().decode("ascii"),
"ENABLED_MODULES": "access,admin,policy,audit",
"CELERY_ENABLED": "true",
"REDIS_URL": "redis://redis.example.internal:6379/0",
"CORS_ORIGINS": "https://govoplan.example.org",
"AUTH_COOKIE_SECURE": "true",
"FILE_STORAGE_BACKEND": "local",
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
},
profile="self-hosted",
)
self.assertTrue(result.ok, result.to_text())
self.assertEqual([], [issue.key for issue in result.errors])
def test_production_validation_rejects_dev_bootstrap_and_sqlite(self) -> None:
result = validate_runtime_configuration(
{
"APP_ENV": "production",
"DATABASE_URL": "sqlite:////tmp/govoplan.db",
"MASTER_KEY_B64": Fernet.generate_key().decode("ascii"),
"ENABLED_MODULES": "access,admin",
"DEV_BOOTSTRAP_ENABLED": "true",
"CORS_ORIGINS": "https://govoplan.example.org",
"AUTH_COOKIE_SECURE": "true",
"FILE_STORAGE_BACKEND": "local",
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
},
profile="self-hosted",
)
error_keys = {issue.key for issue in result.errors}
self.assertIn("DATABASE_URL", error_keys)
self.assertIn("DEV_BOOTSTRAP_ENABLED", error_keys)
def test_env_templates_surface_install_profiles(self) -> None:
self_hosted = env_template(profile="self-hosted")
production_like = env_template(profile="production-like", generate_secrets=True)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
if __name__ == "__main__":
unittest.main()

View File

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