feat: strengthen module contracts and shared WebUI runtime

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 53e947935a
commit 68328f3d8e
57 changed files with 4358 additions and 371 deletions

View File

@@ -25,6 +25,7 @@ from govoplan_core.core.access import (
CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUDIT_SINK,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
AccessDecision,
AccessAdministration,
@@ -1082,16 +1083,62 @@ class AccessContractTests(unittest.TestCase):
def get_organization_function_assignment(self, requested_assignment_id: str):
return assignment if requested_assignment_id == assignment_id else None
def organization_function_assignments_for_identity(self, requested_identity_id: str, *, tenant_id: str | None = None):
def organization_function_assignments_for_identity(
self,
requested_identity_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
def organization_function_assignments_for_account(self, requested_account_id: str, *, tenant_id: str | None = None):
def organization_function_assignments_for_account(
self,
requested_account_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
def organization_function_assignments_for_identities(
self,
identity_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
item_id: self.organization_function_assignments_for_identity(
item_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for item_id in identity_ids
}
def organization_function_assignments_for_accounts(
self,
account_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
item_id: self.organization_function_assignments_for_account(
item_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for item_id in account_ids
}
class FakeOrganizationDirectory(CoreOrganizationDirectory):
def get_organization_unit(self, requested_organization_unit_id: str):
if requested_organization_unit_id != organization_unit_id:
@@ -1317,7 +1364,7 @@ class AccessContractTests(unittest.TestCase):
clear_runtime()
shutil.rmtree(root, ignore_errors=True)
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
def test_api_principal_dependency_uses_auth_provider_capability(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
try:
account_id = "account-capability"
@@ -1346,6 +1393,31 @@ class AccessContractTests(unittest.TestCase):
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
return AccessDecision(allowed=self.has_scope(principal, required_scope), requirements=(required_scope,))
class CapabilityApiPrincipalProvider:
def resolve_api_principal(
self,
request,
session,
*,
authorization=None,
x_api_key=None,
):
del authorization, x_api_key
principal_ref = CapabilityPrincipalResolver().resolve_request(
request,
session=session,
)
account = session.get(Account, principal_ref.account_id)
user = session.get(User, principal_ref.membership_id)
assert account is not None
assert user is not None
return ApiPrincipal(
principal=principal_ref,
account=account,
user=user,
permission_evaluator=CapabilityPermissionEvaluator(),
)
router = APIRouter()
@router.get("/capability-principal")
@@ -1365,6 +1437,7 @@ class AccessContractTests(unittest.TestCase):
capability_factories={
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: CapabilityPrincipalResolver(),
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: CapabilityPermissionEvaluator(),
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: lambda context: CapabilityApiPrincipalProvider(),
},
)
@@ -1372,7 +1445,7 @@ class AccessContractTests(unittest.TestCase):
title="access contract test",
version="test",
settings=SimpleNamespace(database_url=f"sqlite:///{root / 'test.db'}"),
enabled_modules=(),
enabled_modules=(ACCESS_MODULE_ID,),
manifest_factories=(access_manifest,),
base_routers=(router,),
post_module_routers=(),

View File

@@ -52,6 +52,7 @@ engine = _database.engine
SessionLocal = _database.SessionLocal
from govoplan_core.server.app import app
from govoplan_campaign.backend.sending.execution import SNAPSHOT_VERSION
class ApiSmokeTests(unittest.TestCase):
@@ -764,7 +765,7 @@ class ApiSmokeTests(unittest.TestCase):
assert version is not None
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
snapshot = version.execution_snapshot or {}
self.assertEqual(snapshot["snapshot_version"], "5")
self.assertEqual(snapshot["snapshot_version"], SNAPSHOT_VERSION)
self.assertEqual(snapshot["mail_profile_id"], profile_id)
self.assertNotIn("smtp", snapshot)
self.assertNotIn("imap", snapshot)
@@ -774,8 +775,10 @@ class ApiSmokeTests(unittest.TestCase):
deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers)
self.assertEqual(deleted.status_code, 200, deleted.text)
self.assertFalse(deleted.json()["is_active"])
self.assertFalse(deleted.json()["smtp_password_configured"])
self.assertFalse(deleted.json()["imap_password_configured"])
# Reusable hierarchy credentials outlive a deactivated profile. The
# profile's retired legacy secret columns are still scrubbed below.
self.assertTrue(deleted.json()["smtp_password_configured"])
self.assertTrue(deleted.json()["imap_password_configured"])
from govoplan_audit.backend.db.models import AuditLog
@@ -864,7 +867,10 @@ class ApiSmokeTests(unittest.TestCase):
self.assertIsNotNone(version)
assert version is not None
snapshot_payload = version.execution_snapshot or {}
self.assertEqual(snapshot_payload["snapshot_version"], "5")
self.assertEqual(
snapshot_payload["snapshot_version"],
SNAPSHOT_VERSION,
)
self.assertEqual(snapshot_payload["mail_profile_id"], profile_id)
self.assertNotIn("smtp", snapshot_payload)
self.assertNotIn("imap", snapshot_payload)
@@ -2945,7 +2951,7 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
self.assertEqual(
delta_payload["jobs"][0]["last_error"],
"SMTP delivery outcome requires operator reconciliation.",
"Delivery outcome requires operator reconciliation.",
)
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
@@ -4172,7 +4178,7 @@ class ApiSmokeTests(unittest.TestCase):
)
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt
from govoplan_mail.backend.db.models import MailServerProfile
from govoplan_mail.backend.db.models import MailServerEndpoint, MailServerProfile
with SessionLocal() as session:
version = session.get(CampaignVersion, version_id)
@@ -4180,7 +4186,7 @@ class ApiSmokeTests(unittest.TestCase):
assert version is not None
snapshot = version.execution_snapshot
self.assertIsInstance(snapshot, dict)
self.assertEqual(snapshot["snapshot_version"], "5")
self.assertEqual(snapshot["snapshot_version"], SNAPSHOT_VERSION)
self.assertEqual(snapshot["job_count"], 1)
self.assertEqual(snapshot["queueable_job_count"], 1)
self.assertTrue(snapshot["job_manifest_sha256"])
@@ -4195,9 +4201,17 @@ class ApiSmokeTests(unittest.TestCase):
profile = session.get(MailServerProfile, snapshot["mail_profile_id"])
self.assertIsNotNone(profile)
assert profile is not None
profile.smtp_config = {**profile.smtp_config, "host": "smtp.example.invalid"}
profile.smtp_transport_revision = "manually-rotated-transport-revision"
session.add(profile)
smtp_server_id = snapshot.get("smtp_server_id")
self.assertTrue(smtp_server_id)
smtp_server = session.get(MailServerEndpoint, smtp_server_id)
self.assertIsNotNone(smtp_server)
assert smtp_server is not None
smtp_server.config = {
**smtp_server.config,
"host": "smtp.example.invalid",
}
smtp_server.transport_revision = "manually-rotated-transport-revision"
session.add(smtp_server)
session.commit()
sent = self.client.post(

View File

@@ -19,10 +19,25 @@ from govoplan_core.core.change_sequence import (
sequence_watermark_is_expired,
)
from govoplan_core.core.events import EventActorRef, EventBus, EventObjectRef, EventTenantRef, PlatformEvent, event_bus_context
from govoplan_core.core.runtime import (
clear_runtime,
configure_runtime,
get_runtime_context,
)
from govoplan_core.db.base import Base
class ChangeSequenceTests(unittest.TestCase):
def setUp(self) -> None:
self._runtime_context = get_runtime_context()
clear_runtime()
def tearDown(self) -> None:
if self._runtime_context is not None:
configure_runtime(self._runtime_context)
else:
clear_runtime()
def test_records_changes_and_returns_entries_after_watermark(self) -> None:
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine)
@@ -93,6 +108,8 @@ class ChangeSequenceTests(unittest.TestCase):
actor_id="user-1",
payload={"scope_type": "tenant"},
)
self.assertEqual([], seen)
session.commit()
self.assertEqual([case[-1] for case in cases], [event.type for event in seen])
event = seen[1]
@@ -105,6 +122,36 @@ class ChangeSequenceTests(unittest.TestCase):
finally:
engine.dispose()
def test_record_change_discards_event_when_transaction_rolls_back(self) -> None:
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine)
try:
Base.metadata.create_all(
bind=engine,
tables=[
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
],
)
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
with SessionLocal() as session, event_bus_context(bus):
record_change(
session,
tenant_id="tenant-1",
module_id="files",
collection="files.assets",
resource_type="file",
resource_id="file-1",
operation="created",
)
session.rollback()
self.assertEqual([], seen)
finally:
engine.dispose()
def test_pruning_records_retention_floor_for_stale_watermarks(self) -> None:
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine)

View File

@@ -6,6 +6,7 @@ from fastapi import APIRouter, Response
from fastapi.responses import PlainTextResponse
from fastapi.testclient import TestClient
from govoplan_core.auth import get_api_principal
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
@@ -85,6 +86,7 @@ class ConditionalRequestTests(unittest.TestCase):
registry=PlatformRegistry(),
api_router=api_router,
)
app.dependency_overrides[get_api_principal] = lambda: object()
with TestClient(app) as client:
for path in (

View File

@@ -8,6 +8,8 @@ from unittest.mock import patch
from fastapi import APIRouter, Request
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event, audit_operation_context
from govoplan_core.core.events import (
@@ -17,6 +19,7 @@ from govoplan_core.core.events import (
EventTenantRef,
PlatformEvent,
current_event_trace,
emit_platform_event,
event_bus_context,
event_context,
normalize_trace_id,
@@ -31,13 +34,60 @@ from tests.db_isolation import temporary_database
def _configure_audit_runtime() -> None:
registry = build_platform_registry(("audit",))
registry = build_platform_registry(("access", "audit"))
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
class CoreEventTests(unittest.TestCase):
def test_fallback_event_waits_for_outer_commit_after_savepoint(self) -> None:
engine = create_engine("sqlite:///:memory:")
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
try:
with Session(engine) as session, event_bus_context(bus):
with session.begin():
with session.begin_nested():
emit_platform_event(
session,
PlatformEvent(type="nested.created", module_id="core"),
)
self.assertEqual([], seen)
self.assertEqual(
["nested.created"],
[event.type for event in seen],
)
finally:
engine.dispose()
def test_fallback_event_discards_only_rolled_back_savepoint(self) -> None:
engine = create_engine("sqlite:///:memory:")
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
try:
with Session(engine) as session, event_bus_context(bus):
with session.begin():
emit_platform_event(
session,
PlatformEvent(type="outer.created", module_id="core"),
)
savepoint = session.begin_nested()
emit_platform_event(
session,
PlatformEvent(type="nested.created", module_id="core"),
)
savepoint.rollback()
self.assertEqual([], seen)
self.assertEqual(
["outer.created"],
[event.type for event in seen],
)
finally:
engine.dispose()
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
bus = EventBus()
seen: list[PlatformEvent] = []
@@ -267,6 +317,11 @@ class CoreEventTests(unittest.TestCase):
object_id="user-2",
details={"password": "secret", "field": "display_name"},
)
session.commit()
from govoplan_audit.backend.outbox import SqlAuditOutbox
SqlAuditOutbox().dispatch_pending(session)
session.commit()
action_events = [event for event in seen if event.type == "user.updated"]
self.assertEqual(1, len(action_events))
@@ -315,6 +370,11 @@ class CoreEventTests(unittest.TestCase):
object_type="demo",
object_id=action,
)
session.commit()
from govoplan_audit.backend.outbox import SqlAuditOutbox
SqlAuditOutbox().dispatch_pending(session)
session.commit()
by_type = {event.type: event.module_id for event in seen if event.type in cases}
self.assertEqual(cases, by_type)

View File

@@ -131,8 +131,15 @@ class DatabaseMigrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory(prefix="govoplan-core-baseline-test-") as directory:
database = Path(directory) / "core.db"
url = f"sqlite:///{database}"
enabled_modules = ("access",)
command.upgrade(alembic_config(database_url=url, enabled_modules=()), "heads")
command.upgrade(
alembic_config(
database_url=url,
enabled_modules=enabled_modules,
),
"heads",
)
engine = create_engine(url)
try:
@@ -157,7 +164,13 @@ class DatabaseMigrationTests(unittest.TestCase):
).scalar_one()
current = database_migration_heads(connection)
self.assertEqual(current, configured_migration_heads(url, enabled_modules=()))
self.assertEqual(
current,
configured_migration_heads(
url,
enabled_modules=enabled_modules,
),
)
self.assertIn("core_scopes", tables)
self.assertNotIn("tenancy_tenants", tables)
self.assertIn("access_accounts", tables)

View File

@@ -119,20 +119,66 @@ class _FakeIdmDirectory:
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
return self.assignment if assignment_id == self.assignment.id else None
def organization_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,
effective_at=None,
):
del effective_at
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 organization_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,
effective_at=None,
):
del effective_at
if identity_id != self.assignment.identity_id:
return ()
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
return ()
return (self.assignment,)
def organization_function_assignments_for_identities(
self,
identity_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
identity_id: self.organization_function_assignments_for_identity(
identity_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for identity_id in identity_ids
}
def organization_function_assignments_for_accounts(
self,
account_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
account_id: self.organization_function_assignments_for_account(
account_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for account_id in account_ids
}
class IdentityOrganizationContractTests(unittest.TestCase):
def test_protocol_shapes_match_reference_implementations(self) -> None:

View File

@@ -230,7 +230,10 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ())
self.assertTrue(manifests["campaigns"].required_capabilities)
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail", "notifications", "addresses"))
self.assertEqual(
manifests["campaigns"].optional_dependencies,
("files", "mail", "notifications", "addresses", "postbox"),
)
self.assertEqual(manifests["dashboard"].dependencies, ())
self.assertTrue(manifests["dashboard"].required_capabilities)
self.assertEqual(manifests["docs"].dependencies, ())
@@ -587,7 +590,7 @@ class ModuleSystemTests(unittest.TestCase):
def test_enabled_module_permutations_register_expected_routes(self) -> None:
cases = (
("core_only", (), {"access"}, set()),
("core_only", (), set(), set()),
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
@@ -602,8 +605,14 @@ class ModuleSystemTests(unittest.TestCase):
app, _settings_obj = self._app_for_modules(enabled_modules)
route_paths = _route_paths(app)
self.assertIn("/api/v1/platform/modules", route_paths)
self.assertIn("/api/v1/auth/login", route_paths)
self.assertIn("/api/v1/admin/users", route_paths)
self.assertEqual(
"access" in expected_modules,
"/api/v1/auth/login" in route_paths,
)
self.assertEqual(
"access" in expected_modules,
"/api/v1/admin/users" in route_paths,
)
for prefix in ("/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"):
has_prefix = any(path == prefix or path.startswith(f"{prefix}/") for path in route_paths)
self.assertEqual(prefix in expected_prefixes, has_prefix, f"{name}: {prefix}")
@@ -613,12 +622,19 @@ class ModuleSystemTests(unittest.TestCase):
self.assertEqual(health.status_code, 200, health.text)
self.assertEqual({"status": "ok"}, health.json())
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(
401 if "access" in expected_modules else 503,
response.status_code,
response.text,
)
payload_modules = {
item.id
for item in app.state.govoplan_registry.manifests()
}
self.assertEqual(expected_modules, payload_modules)
def test_governance_template_routes_are_contributed_by_admin_module(self) -> None:
access_only_app, _settings_obj = self._app_for_modules(())
access_only_app, _settings_obj = self._app_for_modules(("access",))
access_only_paths = _route_paths(access_only_app)
self.assertIn("/api/v1/admin/users", access_only_paths)
self.assertNotIn("/api/v1/admin/system/governance-templates", access_only_paths)
@@ -827,6 +843,37 @@ finally:
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
registry.validate()
def test_registry_keeps_optional_auth_modules_access_free(self) -> None:
for modules in ((), ("poll",), ("evaluation",), ("scheduling",)):
with self.subTest(modules=modules):
registry = build_platform_registry(modules)
self.assertFalse(registry.has_module("access"))
def test_registry_prefers_selected_auth_provider_over_default(self) -> None:
provider = ModuleManifest(
id="alternative_auth",
name="Alternative auth",
version="test",
capability_factories={
"auth.principalResolver": lambda _context: object(),
},
)
consumer = ModuleManifest(
id="consumer",
name="Consumer",
version="test",
required_capabilities=("auth.principalResolver",),
)
registry = build_platform_registry(
("alternative_auth", "consumer"),
manifest_factories=(lambda: provider, lambda: consumer),
)
self.assertFalse(registry.has_module("access"))
self.assertTrue(registry.has_module("alternative_auth"))
self.assertTrue(registry.has_module("consumer"))
def test_registry_resolves_required_interface_ranges(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
@@ -903,7 +950,7 @@ finally:
plan = plan_desired_enabled_modules(("campaigns",), manifests)
self.assertEqual(("access", "admin", "campaigns"), plan.enabled_modules)
self.assertEqual((), plan.added_dependencies)
self.assertEqual(("access",), plan.added_dependencies)
def test_module_install_plan_is_saved_alongside_desired_state(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-", dir=_TEST_ROOT))
@@ -3300,7 +3347,10 @@ finally:
"version_min": "0.2.0",
"version_max_exclusive": "0.3.0",
}, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
self.assertEqual(
["files", "mail", "notifications", "addresses", "postbox"],
modules["campaigns"]["optional_dependencies"],
)
self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.9", modules["files"]["version"])
@@ -3946,8 +3996,10 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(response.status_code, 401, response.text)
payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "files"}, payload_modules)
def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None:
@@ -3979,8 +4031,10 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(response.status_code, 401, response.text)
payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "files"}, payload_modules)
def test_create_app_preserves_admin_when_configured_and_saved_state_is_older(self) -> None:
@@ -4012,8 +4066,10 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(response.status_code, 401, response.text)
payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "admin", "files"}, payload_modules)
def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None:
@@ -4023,27 +4079,37 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual(response.status_code, 503, response.text)
self.assertEqual((), lifecycle.active_module_ids())
self.assertFalse("/api/v1/files" in _route_paths(app))
result = lifecycle.apply_enabled_modules(("files",), migrate=False)
self.assertEqual(("files",), result.activated_modules)
self.assertEqual(("access", "files"), result.activated_modules)
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"access", "files"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual(response.status_code, 401, response.text)
self.assertEqual(("access", "files"), lifecycle.active_module_ids())
self.assertTrue("/api/v1/files" in _route_paths(app))
self.assertEqual(401, client.get("/api/v1/files").status_code)
result = lifecycle.apply_enabled_modules((), migrate=False)
self.assertEqual(("files",), result.deactivated_modules)
self.assertEqual(("access", "files"), result.deactivated_modules)
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual(response.status_code, 503, response.text)
self.assertEqual((), lifecycle.active_module_ids())
disabled_response = client.get("/api/v1/files")
self.assertEqual(404, disabled_response.status_code)
self.assertEqual("Module is disabled: files", disabled_response.json()["detail"])
def test_module_lifecycle_live_apply_defaults_by_environment(self) -> None:
app, settings = self._app_for_modules(())
lifecycle = app.state.govoplan_lifecycle
self.assertTrue(lifecycle.live_apply_enabled())
settings.app_env = "production"
self.assertFalse(lifecycle.live_apply_enabled())
settings.module_live_apply_enabled = True
self.assertTrue(lifecycle.live_apply_enabled())
def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
cases = (
@@ -4073,10 +4139,9 @@ finally:
def test_module_migrations_are_registered_only_for_enabled_modules(self) -> None:
core_plan = migration_metadata_plan(build_platform_registry(()))
self.assertEqual(1, len(core_plan.script_locations))
self.assertTrue(core_plan.script_locations[0].endswith("govoplan_access/backend/migrations/versions"))
self.assertEqual(1, len(core_plan.metadata))
self.assertEqual(("access",), tuple(item.module_id for item in core_plan.modules))
self.assertEqual((), core_plan.script_locations)
self.assertEqual((), core_plan.metadata)
self.assertEqual((), core_plan.modules)
files_plan = migration_metadata_plan(build_platform_registry(("files",)))
self.assertEqual(2, len(files_plan.script_locations))

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
PostboxAccessProvider,
PostboxDeliveryProvider,
PostboxDirectoryProvider,
PostboxEvidenceProvider,
PostboxMessagesProvider,
postbox_access_provider,
postbox_delivery_provider,
postbox_directory_provider,
postbox_evidence_provider,
postbox_messages_provider,
)
from govoplan_core.core.registry import PlatformRegistry
class _PostboxProvider:
def list_visible_postboxes(self, session, *, tenant_id, actor):
del session, tenant_id, actor
return ()
def resolve_postbox(self, session, *, tenant_id, target, materialize=False):
del session, tenant_id, target, materialize
return None
def delivery_catalog(self, session, *, tenant_id):
del session, tenant_id
return None
def explain_access(self, session, *, tenant_id, postbox_id, actor, action):
del session, tenant_id, postbox_id, actor, action
return None
def list_messages(
self,
session,
*,
tenant_id,
postbox_ids,
actor,
limit=100,
offset=0,
):
del session, tenant_id, postbox_ids, actor, limit, offset
return ()
def get_message(self, session, *, tenant_id, message_id, actor):
del session, tenant_id, message_id, actor
return None
def mark_message(self, session, *, tenant_id, message_id, actor, state):
del session, tenant_id, message_id, actor, state
return None
def deliver(self, session, request):
del session, request
return None
def link_evidence(self, session, *, tenant_id, message_id, attachment):
del session, tenant_id, message_id, attachment
return None
class PostboxContractTests(unittest.TestCase):
def test_protocols_and_registry_helpers_resolve_without_module_imports(self) -> None:
provider = _PostboxProvider()
self.assertIsInstance(provider, PostboxDirectoryProvider)
self.assertIsInstance(provider, PostboxAccessProvider)
self.assertIsInstance(provider, PostboxMessagesProvider)
self.assertIsInstance(provider, PostboxDeliveryProvider)
self.assertIsInstance(provider, PostboxEvidenceProvider)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="postbox_contract_test",
name="Postbox contract test",
version="test",
capability_factories={
name: lambda context: provider
for name in (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
)
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
self.assertIs(provider, postbox_directory_provider(registry))
self.assertIs(provider, postbox_access_provider(registry))
self.assertIs(provider, postbox_messages_provider(registry))
self.assertIs(provider, postbox_delivery_provider(registry))
self.assertIs(provider, postbox_evidence_provider(registry))
empty = PlatformRegistry()
self.assertIsNone(postbox_directory_provider(empty))
self.assertIsNone(postbox_access_provider(empty))
self.assertIsNone(postbox_messages_provider(empty))
self.assertIsNone(postbox_delivery_provider(empty))
self.assertIsNone(postbox_evidence_provider(empty))
if __name__ == "__main__":
unittest.main()

View File

@@ -15,6 +15,55 @@ from govoplan_core.server.fastapi import create_govoplan_app
class QueryMetricsTests(TestCase):
def test_non_sqlite_engine_uses_bounded_pool_configuration(self) -> None:
configured = {
"GOVOPLAN_DB_POOL_SIZE": "8",
"GOVOPLAN_DB_MAX_OVERFLOW": "16",
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": "45",
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": "900",
}
engine = object()
with (
patch.dict(os.environ, configured, clear=False),
patch(
"govoplan_core.db.session.create_engine",
return_value=engine,
) as create_engine,
patch(
"govoplan_core.db.session.instrument_engine",
side_effect=lambda value: value,
),
):
result = create_database_engine(
"postgresql+psycopg://govoplan@example/govoplan",
)
self.assertIs(engine, result)
create_engine.assert_called_once_with(
"postgresql+psycopg://govoplan@example/govoplan",
pool_pre_ping=True,
connect_args={},
pool_size=8,
max_overflow=16,
pool_timeout=45,
pool_recycle=900,
)
def test_non_sqlite_engine_rejects_pool_values_outside_safe_bounds(self) -> None:
invalid_values = (
("GOVOPLAN_DB_POOL_SIZE", "0"),
("GOVOPLAN_DB_MAX_OVERFLOW", "201"),
("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", "not-a-number"),
("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", "86401"),
)
for name, value in invalid_values:
with self.subTest(name=name, value=value):
with patch.dict(os.environ, {name: value}, clear=False):
with self.assertRaisesRegex(ValueError, name):
create_database_engine(
"postgresql+psycopg://govoplan@example/govoplan",
)
def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None:
engine = create_database_engine("sqlite:///:memory:")
try:

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccountRef,
GroupRef,
UserRef,
)
from govoplan_core.core.references import (
access_scope_reference_options,
validate_access_scope_reference,
)
class _Directory:
users = (
UserRef(
id="membership-1",
account_id="account-1",
tenant_id="tenant-1",
email="ada@example.test",
display_name="Ada",
),
UserRef(
id="membership-2",
account_id="account-2",
tenant_id="tenant-1",
email="inactive@example.test",
display_name="Inactive",
status="inactive",
),
)
groups = (
GroupRef(id="group-1", tenant_id="tenant-1", name="Finance"),
GroupRef(
id="group-2",
tenant_id="tenant-1",
name="Former team",
status="inactive",
),
)
def get_account(self, account_id):
if account_id not in {"account-1", "account-2"}:
return None
return AccountRef(id=account_id, email=f"{account_id}@example.test")
def get_user(self, user_id):
return next((item for item in self.users if item.id == user_id), None)
def get_users(self, user_ids):
return {item.id: item for item in self.users if item.id in set(user_ids)}
def users_for_tenant(self, tenant_id):
return tuple(item for item in self.users if item.tenant_id == tenant_id)
def get_group(self, group_id):
return next((item for item in self.groups if item.id == group_id), None)
def get_groups(self, group_ids):
return {item.id: item for item in self.groups if item.id in set(group_ids)}
def groups_for_tenant(self, tenant_id):
return tuple(item for item in self.groups if item.tenant_id == tenant_id)
def groups_for_user(self, user_id, *, tenant_id):
del user_id
return self.groups_for_tenant(tenant_id)
def display_label(self, subject):
return subject.label
class _Registry:
directory = _Directory()
def has_capability(self, name):
return name == CAPABILITY_ACCESS_DIRECTORY
def capability(self, name):
return self.directory if self.has_capability(name) else None
class ReferenceOptionTests(unittest.TestCase):
def setUp(self) -> None:
self.principal = SimpleNamespace(
tenant_id="tenant-1",
account_id="account-1",
display_name="Ada",
email="ada@example.test",
group_ids=frozenset({"group-1"}),
)
def test_user_values_are_canonical_account_ids(self) -> None:
options = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="user",
administrative=True,
)
self.assertEqual(["account-1", "account-2"], [item.value for item in options])
self.assertEqual("membership-1", options[0].provenance["membership_id"])
self.assertTrue(options[1].disabled)
self.assertEqual("inactive", options[1].availability)
def test_unavailable_selected_values_remain_readable(self) -> None:
options = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="group",
query="missing",
selected_values=("removed-group",),
administrative=True,
)
self.assertEqual(1, len(options))
self.assertEqual("removed-group", options[0].value)
self.assertEqual("unavailable", options[0].availability)
self.assertTrue(options[0].disabled)
def test_non_administrators_only_receive_permitted_scope_targets(self) -> None:
users = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="user",
administrative=False,
)
groups = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="group",
administrative=False,
)
self.assertEqual(["account-1"], [item.value for item in users])
self.assertEqual(["group-1"], [item.value for item in groups])
def test_inactive_targets_are_rejected_but_existing_values_survive(self) -> None:
with self.assertRaisesRegex(ValueError, "Inactive groups"):
validate_access_scope_reference(
_Registry(),
tenant_id="tenant-1",
scope_type="group",
scope_id="group-2",
)
self.assertEqual(
"group-2",
validate_access_scope_reference(
_Registry(),
tenant_id="tenant-1",
scope_type="group",
scope_id="group-2",
preserve_existing="group-2",
),
)
def test_missing_optional_access_provider_keeps_reduced_mode(self) -> None:
options = access_scope_reference_options(
None,
self.principal,
scope_type="user",
)
self.assertEqual("account-1", options[0].value)
self.assertEqual("core", options[0].source_module)
if __name__ == "__main__":
unittest.main()

View File

@@ -5,6 +5,7 @@ import unittest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from govoplan_core.auth import get_api_principal
from govoplan_core.core.modules import (
FrontendModule,
FrontendRoute,
@@ -218,6 +219,7 @@ class ViewSurfaceContractTests(unittest.TestCase):
app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(create_platform_router(), prefix="/api/v1")
app.dependency_overrides[get_api_principal] = lambda: object()
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")

View File

@@ -13,8 +13,6 @@ import unittest
class WheelRuntimeTests(unittest.TestCase):
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
repository_root = Path(__file__).resolve().parents[1]
access_repository = repository_root.parent / "govoplan-access"
self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository")
with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
temporary_root = Path(directory)
wheel_directory = temporary_root / "wheels"
@@ -30,21 +28,8 @@ class WheelRuntimeTests(unittest.TestCase):
str(repository_root),
cwd=temporary_root,
)
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(access_repository),
cwd=temporary_root,
)
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl"))
self.assertEqual(1, len(core_wheels))
self.assertEqual(1, len(access_wheels))
virtual_environment = temporary_root / "venv"
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
@@ -56,7 +41,6 @@ class WheelRuntimeTests(unittest.TestCase):
"install",
"--no-deps",
str(core_wheels[0]),
str(access_wheels[0]),
cwd=temporary_root,
)
@@ -85,7 +69,7 @@ class WheelRuntimeTests(unittest.TestCase):
self.assertNotEqual(repository_root, runtime_root)
self.assertTrue((runtime_root / "alembic.ini").is_file())
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
self.assertIn("4f2a9c8e7b6d", result["heads"])
self.assertEqual(["c91f0a72be34"], result["heads"])
self.assertIn("core_scopes", result["tables"])
self.assertIn("core_system_settings", result["tables"])