Prepare GovOPlaN self-hosted release workflow
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
403
tests/test_idm_api.py
Normal 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()
|
||||
76
tests/test_install_config.py
Normal file
76
tests/test_install_config.py
Normal 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()
|
||||
@@ -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