feat: add institutional governance and recovery contracts
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationModuleRequirement,
|
||||
ConfigurationPackageEvidence,
|
||||
ConfigurationPackageManifest,
|
||||
ConfigurationPackageParent,
|
||||
ConfigurationPreflightContext,
|
||||
ConfigurationProviderExpectation,
|
||||
dry_run_configuration_package,
|
||||
validate_configuration_package_derivation,
|
||||
)
|
||||
|
||||
|
||||
def _evidence(*kinds: str) -> tuple[ConfigurationPackageEvidence, ...]:
|
||||
return tuple(
|
||||
ConfigurationPackageEvidence(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
reference=f"evidence/{kind}.json",
|
||||
summary=f"Evidence for {kind}.",
|
||||
checksum=f"sha256:{'a' * 64}",
|
||||
)
|
||||
for kind in kinds
|
||||
)
|
||||
|
||||
|
||||
class ConfigurationPackageArchitectureTests(unittest.TestCase):
|
||||
def test_legacy_package_defaults_to_product_and_round_trips(self) -> None:
|
||||
package = ConfigurationPackageManifest.from_mapping(
|
||||
{"package_id": "example", "name": "Example", "version": "1.0.0"}
|
||||
)
|
||||
|
||||
self.assertEqual(package.package_class, "product")
|
||||
self.assertEqual(
|
||||
ConfigurationPackageManifest.from_mapping(package.to_dict()),
|
||||
package,
|
||||
)
|
||||
|
||||
def test_reference_package_requires_operational_evidence(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "accessibility"):
|
||||
ConfigurationPackageManifest(
|
||||
package_id="reference.example",
|
||||
name="Example reference",
|
||||
version="1.0.0",
|
||||
package_class="reference",
|
||||
evidence=_evidence("target_test", "recovery"),
|
||||
)
|
||||
|
||||
def test_reference_evidence_requires_content_checksums(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "without checksums"):
|
||||
ConfigurationPackageManifest(
|
||||
package_id="reference.unbound",
|
||||
name="Unbound reference",
|
||||
version="1.0.0",
|
||||
package_class="reference",
|
||||
evidence=tuple(
|
||||
ConfigurationPackageEvidence(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
reference=f"evidence/{kind}.json",
|
||||
summary=f"{kind} evidence.",
|
||||
checksum=(
|
||||
None
|
||||
if kind == "target_test"
|
||||
else f"sha256:{'b' * 64}"
|
||||
),
|
||||
)
|
||||
for kind in (
|
||||
"target_test",
|
||||
"recovery",
|
||||
"security",
|
||||
"operations",
|
||||
"accessibility",
|
||||
"privacy",
|
||||
"documentation",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_integration_package_preflight_checks_provider_contract_and_health(self) -> None:
|
||||
package = ConfigurationPackageManifest(
|
||||
package_id="integration.example",
|
||||
name="Example integration",
|
||||
version="1.0.0",
|
||||
package_class="integration",
|
||||
evidence=_evidence(
|
||||
"target_test", "recovery", "operations", "documentation"
|
||||
),
|
||||
provider_expectations=(
|
||||
ConfigurationProviderExpectation(
|
||||
provider_id="connectors.example",
|
||||
authority_mode="external_mirror",
|
||||
minimum_maturity="read",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
healthy = dry_run_configuration_package(
|
||||
package,
|
||||
(),
|
||||
ConfigurationPreflightContext(
|
||||
external_provider_declarations={
|
||||
"connectors.example": {
|
||||
"authority_modes": ["external_mirror"],
|
||||
"maturity": "synchronize",
|
||||
}
|
||||
},
|
||||
external_provider_states={
|
||||
"connectors.example": {"health": "healthy"}
|
||||
},
|
||||
),
|
||||
)
|
||||
unhealthy = dry_run_configuration_package(
|
||||
package,
|
||||
(),
|
||||
ConfigurationPreflightContext(
|
||||
external_provider_declarations={
|
||||
"connectors.example": {
|
||||
"authority_modes": ["external_authoritative"],
|
||||
"maturity": "discover",
|
||||
}
|
||||
},
|
||||
external_provider_states={
|
||||
"connectors.example": {"health": "error"}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
any(item.severity == "blocker" for item in healthy.diagnostics)
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"external_provider_authority_mode_unsupported",
|
||||
"external_provider_maturity_insufficient",
|
||||
"external_provider_unhealthy",
|
||||
},
|
||||
{item.code for item in unhealthy.diagnostics},
|
||||
)
|
||||
|
||||
def test_provider_preflight_selects_the_requested_runtime_binding(self) -> None:
|
||||
package = ConfigurationPackageManifest(
|
||||
package_id="integration.calendar",
|
||||
name="Calendar integration",
|
||||
version="1.0.0",
|
||||
package_class="integration",
|
||||
evidence=_evidence(
|
||||
"target_test", "recovery", "operations", "documentation"
|
||||
),
|
||||
provider_expectations=(
|
||||
ConfigurationProviderExpectation(
|
||||
provider_id="calendar.caldav_sync",
|
||||
authority_mode="governed_sync",
|
||||
minimum_maturity="synchronize",
|
||||
binding_ref="calendar:sync-source:required",
|
||||
freshness_expectation="current",
|
||||
recovery_expectation="ready",
|
||||
),
|
||||
),
|
||||
)
|
||||
context = ConfigurationPreflightContext(
|
||||
external_provider_declarations={
|
||||
"calendar.caldav_sync": {
|
||||
"authority_modes": ["governed_sync"],
|
||||
"maturity": "synchronize",
|
||||
"behavior": {},
|
||||
}
|
||||
},
|
||||
external_provider_states={
|
||||
"calendar.caldav_sync": {
|
||||
"health": "warning",
|
||||
"bindings": [
|
||||
{
|
||||
"binding_ref": "calendar:sync-source:other",
|
||||
"authority_mode": "governed_sync",
|
||||
"health": "error",
|
||||
"freshness": "stale",
|
||||
"recovery": "attention",
|
||||
},
|
||||
{
|
||||
"binding_ref": "calendar:sync-source:required",
|
||||
"authority_mode": "governed_sync",
|
||||
"health": "healthy",
|
||||
"freshness": "current",
|
||||
"recovery": "ready",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
result = dry_run_configuration_package(package, (), context)
|
||||
|
||||
self.assertFalse(
|
||||
any(item.severity == "blocker" for item in result.diagnostics)
|
||||
)
|
||||
|
||||
def test_derived_package_may_tighten_but_not_remove_parent_constraints(self) -> None:
|
||||
parent = ConfigurationPackageManifest(
|
||||
package_id="product.base",
|
||||
name="Base",
|
||||
version="1.0.0",
|
||||
required_modules=(
|
||||
ConfigurationModuleRequirement("connectors", "1.0.0"),
|
||||
),
|
||||
required_capabilities=("connectors.profiles",),
|
||||
provider_expectations=(
|
||||
ConfigurationProviderExpectation(
|
||||
provider_id="connectors.example",
|
||||
authority_mode="external_mirror",
|
||||
minimum_maturity="read",
|
||||
),
|
||||
),
|
||||
)
|
||||
child = ConfigurationPackageManifest(
|
||||
package_id="sector.example",
|
||||
name="Sector example",
|
||||
version="1.0.0",
|
||||
package_class="sector",
|
||||
parents=(
|
||||
ConfigurationPackageParent("product.base", "1.0.0"),
|
||||
),
|
||||
evidence=_evidence("documentation"),
|
||||
)
|
||||
|
||||
codes = {
|
||||
item.code
|
||||
for item in validate_configuration_package_derivation(child, parent)
|
||||
}
|
||||
self.assertEqual(
|
||||
{
|
||||
"package_parent_module_constraint_loosened",
|
||||
"package_parent_capability_constraint_loosened",
|
||||
"package_parent_provider_constraint_removed",
|
||||
},
|
||||
codes,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -15,12 +15,14 @@ class ExternalReferenceContractTests(unittest.TestCase):
|
||||
object_type="work_package",
|
||||
object_id="42",
|
||||
maturity="synchronize",
|
||||
authority_mode="governed_sync",
|
||||
canonical_url="https://projects.example.test/work_packages/42",
|
||||
)
|
||||
|
||||
self.assertEqual("openproject-main:work_package:42", reference.identity_key)
|
||||
self.assertTrue(reference.supports("read"))
|
||||
self.assertFalse(reference.supports("replace"))
|
||||
self.assertEqual("governed_sync", reference.to_dict()["authority_mode"])
|
||||
|
||||
def test_reference_rejects_credentials_in_urls(self) -> None:
|
||||
with self.assertRaises(ExternalReferenceValidationError):
|
||||
@@ -31,6 +33,19 @@ class ExternalReferenceContractTests(unittest.TestCase):
|
||||
canonical_url="https://user:secret@example.test/wiki/Main_Page",
|
||||
)
|
||||
|
||||
def test_authority_mode_must_match_maturity(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ExternalReferenceValidationError,
|
||||
"Governed-sync references require synchronize maturity",
|
||||
):
|
||||
ExternalObjectReference(
|
||||
system="openproject",
|
||||
object_type="work_package",
|
||||
object_id="7",
|
||||
maturity="read",
|
||||
authority_mode="governed_sync",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import subprocess
|
||||
|
||||
from govoplan_core.commands import fenced_run
|
||||
from govoplan_core.core.runtime_coordination import LeaseClaim
|
||||
|
||||
|
||||
class _IgnoringProcess:
|
||||
def __init__(self) -> None:
|
||||
self.terminated = False
|
||||
self.killed = False
|
||||
self.wait_count = 0
|
||||
|
||||
def poll(self):
|
||||
return None if not self.killed else -9
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
|
||||
def kill(self) -> None:
|
||||
self.killed = True
|
||||
|
||||
def wait(self, *, timeout: float):
|
||||
self.wait_count += 1
|
||||
if not self.killed:
|
||||
raise subprocess.TimeoutExpired("command", timeout)
|
||||
return -9
|
||||
|
||||
|
||||
def test_fenced_process_is_killed_when_it_ignores_termination() -> None:
|
||||
process = _IgnoringProcess()
|
||||
|
||||
fenced_run._terminate_process(process, timeout_seconds=0.01)
|
||||
|
||||
assert process.terminated is True
|
||||
assert process.killed is True
|
||||
assert process.wait_count == 2
|
||||
|
||||
|
||||
def test_lease_release_is_best_effort_after_database_loss(monkeypatch) -> None:
|
||||
claim = LeaseClaim(
|
||||
installation_id="installation-1",
|
||||
resource_key="scheduler",
|
||||
holder_node_id="scheduler-1",
|
||||
holder_incarnation="incarnation-1",
|
||||
fencing_token=1,
|
||||
expires_at=datetime(2026, 8, 1, 12, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
fenced_run,
|
||||
"get_database",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("database unavailable")),
|
||||
)
|
||||
|
||||
fenced_run._release(claim)
|
||||
@@ -186,6 +186,44 @@ class InstallConfigTests(unittest.TestCase):
|
||||
).file_storage_s3_deployment_managed
|
||||
)
|
||||
|
||||
def test_external_s3_requires_explicit_https_deployment_trust(self) -> None:
|
||||
base = {
|
||||
"APP_ENV": "development",
|
||||
"FILE_STORAGE_BACKEND": "s3",
|
||||
"FILE_STORAGE_S3_ENDPOINT_URL": "https://s3.example.test",
|
||||
"FILE_STORAGE_S3_REGION": "eu-test-1",
|
||||
"FILE_STORAGE_S3_ACCESS_KEY_ID": "access",
|
||||
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "secret",
|
||||
"FILE_STORAGE_S3_BUCKET": "files",
|
||||
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED": "false",
|
||||
}
|
||||
rejected = validate_runtime_configuration(base, profile="development")
|
||||
accepted = validate_runtime_configuration(
|
||||
{**base, "FILE_STORAGE_S3_ENDPOINT_TRUSTED": "true"},
|
||||
profile="development",
|
||||
)
|
||||
insecure = validate_runtime_configuration(
|
||||
{
|
||||
**base,
|
||||
"FILE_STORAGE_S3_ENDPOINT_URL": "http://s3.example.test",
|
||||
"FILE_STORAGE_S3_ENDPOINT_TRUSTED": "true",
|
||||
},
|
||||
profile="development",
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||
{issue.key for issue in rejected.errors},
|
||||
)
|
||||
self.assertNotIn(
|
||||
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||
{issue.key for issue in accepted.errors},
|
||||
)
|
||||
self.assertIn(
|
||||
"FILE_STORAGE_S3_ENDPOINT_URL",
|
||||
{issue.key for issue in insecure.errors},
|
||||
)
|
||||
|
||||
def test_connector_deployment_allowlists_require_exact_names_and_absolute_paths(self) -> None:
|
||||
result = validate_runtime_configuration(
|
||||
{
|
||||
@@ -215,6 +253,7 @@ class InstallConfigTests(unittest.TestCase):
|
||||
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
|
||||
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", self_hosted)
|
||||
self.assertIn("FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false", self_hosted)
|
||||
self.assertIn("FILE_STORAGE_S3_ENDPOINT_TRUSTED=false", self_hosted)
|
||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||
@@ -229,6 +268,10 @@ class InstallConfigTests(unittest.TestCase):
|
||||
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false",
|
||||
production_like,
|
||||
)
|
||||
self.assertIn(
|
||||
"FILE_STORAGE_S3_ENDPOINT_TRUSTED=false",
|
||||
production_like,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.automation import ActionExecutionRequest, AutomationInvocation
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
from govoplan_core.core.external_references import ExternalObjectReference
|
||||
from govoplan_core.core.institutional import (
|
||||
ActorRepresentationReference,
|
||||
DecisionEffectReference,
|
||||
EvidenceReference,
|
||||
ExternalSourceReference,
|
||||
FormDefinition,
|
||||
FormFieldDefinition,
|
||||
FormalDecision,
|
||||
GovernedContextEnvelope,
|
||||
InformationGovernanceReference,
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
LegalBasisReference,
|
||||
MandateDefinition,
|
||||
MandateResolution,
|
||||
MandateResolutionRequest,
|
||||
PartyRepresentation,
|
||||
PartySubjectReference,
|
||||
ProcedureParty,
|
||||
ServiceBinding,
|
||||
ServiceAvailabilityRequirement,
|
||||
ServiceDefinition,
|
||||
ServiceLaunchRequest,
|
||||
ServiceLaunchResult,
|
||||
TemporalRevision,
|
||||
derive_service_restriction,
|
||||
resolve_mandate_candidates,
|
||||
revise_procedure_party,
|
||||
revise_formal_decision,
|
||||
revise_mandate_definition,
|
||||
revoke_party_representation,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _reference(
|
||||
kind: str,
|
||||
object_id: str,
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
) -> InstitutionalReference:
|
||||
owners = {
|
||||
"institution": "tenancy",
|
||||
"organization_unit": "organizations",
|
||||
"function": "organizations",
|
||||
"function_assignment": "idm",
|
||||
"mandate": "cases",
|
||||
"jurisdiction": "cases",
|
||||
"service": "portal",
|
||||
"form": "forms",
|
||||
"form_submission": "forms_runtime",
|
||||
"case": "cases",
|
||||
"party": "cases",
|
||||
"decision": "committee",
|
||||
"work_item": "workflow_engine",
|
||||
"workflow": "workflow_engine",
|
||||
"approval": "workflow_engine",
|
||||
"record": "audit",
|
||||
}
|
||||
return InstitutionalReference(
|
||||
kind=kind,
|
||||
owner_module=owners[kind],
|
||||
object_id=object_id,
|
||||
tenant_id=tenant_id,
|
||||
version="3",
|
||||
valid_at=NOW,
|
||||
label=f"Protected label for {object_id}",
|
||||
)
|
||||
|
||||
|
||||
def _context() -> GovernedContextEnvelope:
|
||||
external = ExternalObjectReference(
|
||||
system="gis.example",
|
||||
object_type="administrative_area",
|
||||
object_id="area-7",
|
||||
maturity="read",
|
||||
authority_mode="external_mirror",
|
||||
canonical_url="https://gis.example/areas/7",
|
||||
observed_at=NOW,
|
||||
)
|
||||
return GovernedContextEnvelope(
|
||||
tenant_id="tenant-1",
|
||||
temporal=TemporalRevision(
|
||||
revision="7",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=1),
|
||||
recorded_at=NOW,
|
||||
change_reason="Case state changed.",
|
||||
),
|
||||
actor=ActorRepresentationReference(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
represented_function_ref=_reference("function", "function-1"),
|
||||
function_assignment_ref=_reference(
|
||||
"function_assignment", "assignment-1"
|
||||
),
|
||||
mandate_ref=_reference("mandate", "mandate-1"),
|
||||
),
|
||||
institution_ref=_reference("institution", "institution-1"),
|
||||
organization_unit_ref=_reference("organization_unit", "unit-1"),
|
||||
function_ref=_reference("function", "function-1"),
|
||||
mandate_ref=_reference("mandate", "mandate-1"),
|
||||
jurisdiction_refs=(_reference("jurisdiction", "jurisdiction-1"),),
|
||||
service_ref=_reference("service", "service-1"),
|
||||
case_ref=_reference("case", "case-1"),
|
||||
party_refs=(_reference("party", "party-1"),),
|
||||
work_item_ref=_reference("work_item", "work-item-1"),
|
||||
workflow_ref=_reference("workflow", "workflow-1"),
|
||||
approval_refs=(_reference("approval", "approval-1"),),
|
||||
decision_ref=_reference("decision", "decision-1"),
|
||||
record_refs=(_reference("record", "record-1"),),
|
||||
legal_bases=(
|
||||
LegalBasisReference(
|
||||
kind="law",
|
||||
authority="Example legislature",
|
||||
reference="law-1/section-3",
|
||||
version="2026-01",
|
||||
effective_from=NOW - timedelta(days=30),
|
||||
title="Protected legal title",
|
||||
inspection_url="https://law.example/section-3",
|
||||
),
|
||||
),
|
||||
evidence=(
|
||||
EvidenceReference(
|
||||
kind="document",
|
||||
owner_module="files",
|
||||
evidence_id="file-1",
|
||||
tenant_id="tenant-1",
|
||||
version="4",
|
||||
checksum="sha256:abcd",
|
||||
captured_at=NOW,
|
||||
inspection_url="https://govoplan.example/files/file-1",
|
||||
),
|
||||
),
|
||||
information_governance=InformationGovernanceReference(
|
||||
classification="confidential",
|
||||
purposes=("case decision",),
|
||||
legal_basis_refs=("law-1/section-3",),
|
||||
retention_policy_ref="records:case-default",
|
||||
disclosure_state="restricted",
|
||||
),
|
||||
external_sources=(
|
||||
ExternalSourceReference(
|
||||
reference=external,
|
||||
authority_mode="external_mirror",
|
||||
maturity="read",
|
||||
freshness_state="current",
|
||||
health_state="ok",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class InstitutionalContractTests(unittest.TestCase):
|
||||
def test_context_round_trip_preserves_versioned_references(self) -> None:
|
||||
context = _context()
|
||||
|
||||
restored = GovernedContextEnvelope.from_mapping(
|
||||
context.to_dict(include_inspection=True)
|
||||
)
|
||||
|
||||
self.assertEqual(context.tenant_id, restored.tenant_id)
|
||||
self.assertEqual("mandate-1", restored.mandate_ref.object_id)
|
||||
self.assertEqual(
|
||||
"external_mirror", restored.external_sources[0].authority_mode
|
||||
)
|
||||
self.assertEqual("workflow-1", restored.workflow_ref.object_id)
|
||||
self.assertEqual("approval-1", restored.approval_refs[0].object_id)
|
||||
self.assertEqual("record-1", restored.record_refs[0].object_id)
|
||||
self.assertTrue(restored.temporal.effective_at(NOW))
|
||||
|
||||
def test_cross_tenant_reference_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "different tenant"):
|
||||
GovernedContextEnvelope(
|
||||
tenant_id="tenant-1",
|
||||
temporal=TemporalRevision(revision="1", recorded_at=NOW),
|
||||
case_ref=_reference("case", "case-2", tenant_id="tenant-2"),
|
||||
)
|
||||
|
||||
def test_default_serialization_redacts_labels_and_inspection_links(self) -> None:
|
||||
payload = _context().to_dict()
|
||||
|
||||
self.assertIsNone(payload["case_ref"]["label"])
|
||||
self.assertIsNone(payload["legal_bases"][0]["title"])
|
||||
self.assertIsNone(payload["legal_bases"][0]["inspection_url"])
|
||||
self.assertIsNone(payload["evidence"][0]["inspection_url"])
|
||||
|
||||
def test_event_and_action_envelopes_carry_reference_context(self) -> None:
|
||||
context = _context()
|
||||
event = PlatformEvent(
|
||||
type="committee.decision.recorded",
|
||||
module_id="committee",
|
||||
institutional_context=context,
|
||||
)
|
||||
action = ActionExecutionRequest(
|
||||
tenant_id="tenant-1",
|
||||
action_key="committee.decision.publish",
|
||||
input={"decision_ref": "decision-1"},
|
||||
idempotency_key="decision-1:publish:1",
|
||||
invocation=AutomationInvocation(kind="workflow"),
|
||||
institutional_context=context,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
"decision-1",
|
||||
event.to_dict()["institutional_context"]["decision_ref"]["object_id"],
|
||||
)
|
||||
self.assertIs(context, action.institutional_context)
|
||||
|
||||
def test_formal_decision_redacts_reasoning_but_keeps_evidence_refs(self) -> None:
|
||||
context = _context()
|
||||
decision = FormalDecision(
|
||||
reference=_reference("decision", "decision-1"),
|
||||
temporal=context.temporal,
|
||||
decision_type="permit",
|
||||
subject_refs=(_reference("case", "case-1"),),
|
||||
state="decided",
|
||||
authority_context=context,
|
||||
fact_evidence=context.evidence,
|
||||
legal_bases=context.legal_bases,
|
||||
operative_result="Permit granted.",
|
||||
reasoning="Protected reasoning.",
|
||||
)
|
||||
|
||||
redacted = decision.to_dict()
|
||||
protected = decision.to_dict(include_protected=True)
|
||||
|
||||
self.assertIsNone(redacted["reasoning"])
|
||||
self.assertEqual("Protected reasoning.", protected["reasoning"])
|
||||
self.assertEqual(
|
||||
"file-1", redacted["fact_evidence"][0]["evidence_id"]
|
||||
)
|
||||
|
||||
restored = FormalDecision.from_mapping(protected)
|
||||
self.assertEqual("Protected reasoning.", restored.reasoning)
|
||||
self.assertEqual("decision-1", restored.reference.object_id)
|
||||
|
||||
def test_semantic_provider_dtos_round_trip_without_domain_tables(self) -> None:
|
||||
context = _context()
|
||||
mandate = MandateDefinition(
|
||||
reference=_reference("mandate", "mandate-1"),
|
||||
temporal=context.temporal,
|
||||
task_types=("committee.formal_decision",),
|
||||
authority_types=("permit",),
|
||||
organization_unit_refs=(
|
||||
_reference("organization_unit", "unit-1"),
|
||||
),
|
||||
function_refs=(_reference("function", "function-1"),),
|
||||
jurisdiction_refs=context.jurisdiction_refs,
|
||||
legal_bases=context.legal_bases,
|
||||
evidence=context.evidence,
|
||||
)
|
||||
resolution = MandateResolution(
|
||||
competent=True,
|
||||
mandates=(mandate,),
|
||||
explanation="Authority matched.",
|
||||
evidence=context.evidence,
|
||||
)
|
||||
service = ServiceDefinition(
|
||||
reference=_reference("service", "service-1"),
|
||||
key="permit.service",
|
||||
temporal=context.temporal,
|
||||
title="Permit service",
|
||||
audience=("residents",),
|
||||
legal_bases=context.legal_bases,
|
||||
channels=("portal", "mail"),
|
||||
responsible_organization_ref=_reference(
|
||||
"organization_unit", "unit-1"
|
||||
),
|
||||
responsible_function_ref=_reference("function", "function-1"),
|
||||
mandate_ref=_reference("mandate", "mandate-1"),
|
||||
jurisdiction_refs=context.jurisdiction_refs,
|
||||
bindings=(ServiceBinding("workflow", "workflow:permit"),),
|
||||
publication_state="published",
|
||||
)
|
||||
represented = _reference("party", "party-applicant")
|
||||
representative = _reference("party", "party-agent")
|
||||
party = ProcedureParty(
|
||||
reference=represented,
|
||||
procedure_ref=_reference("case", "case-1"),
|
||||
role="applicant",
|
||||
subject=PartySubjectReference(
|
||||
kind="identity",
|
||||
provider="identity",
|
||||
subject_id="identity-1",
|
||||
tenant_id="tenant-1",
|
||||
version="2",
|
||||
),
|
||||
temporal=context.temporal,
|
||||
preferred_channels=("portal",),
|
||||
permitted_channels=("portal", "mail"),
|
||||
delivery_recipient=True,
|
||||
representations=(
|
||||
PartyRepresentation(
|
||||
representative_party_ref=representative,
|
||||
represented_party_ref=represented,
|
||||
power_ref="power-1",
|
||||
permitted_actions=("submit", "receive"),
|
||||
temporal=context.temporal,
|
||||
evidence=context.evidence,
|
||||
),
|
||||
),
|
||||
contact_snapshot_refs=("addresses:snapshot-1",),
|
||||
evidence=context.evidence,
|
||||
)
|
||||
|
||||
restored_resolution = MandateResolution.from_mapping(
|
||||
resolution.to_dict(include_inspection=True)
|
||||
)
|
||||
restored_service = ServiceDefinition.from_mapping(service.to_dict())
|
||||
restored_party = ProcedureParty.from_mapping(
|
||||
party.to_dict(include_inspection=True)
|
||||
)
|
||||
|
||||
self.assertTrue(restored_resolution.competent)
|
||||
self.assertEqual(
|
||||
"committee.formal_decision",
|
||||
restored_resolution.mandates[0].task_types[0],
|
||||
)
|
||||
self.assertEqual("workflow:permit", restored_service.bindings[0].reference)
|
||||
self.assertEqual("identity-1", restored_party.subject.subject_id)
|
||||
self.assertEqual(
|
||||
"party-agent",
|
||||
restored_party.representations[0].representative_party_ref.object_id,
|
||||
)
|
||||
|
||||
def test_invalid_semantic_states_are_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "disclosure state"):
|
||||
InformationGovernanceReference(
|
||||
classification="internal",
|
||||
purposes=("test",),
|
||||
disclosure_state="invalid", # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def test_service_launch_contract_requires_exact_same_tenant_targets(self) -> None:
|
||||
service_ref = _reference("service", "service-1")
|
||||
binding = ServiceBinding("case", "permit")
|
||||
request = ServiceLaunchRequest(
|
||||
service_ref=service_ref,
|
||||
binding=binding,
|
||||
idempotency_key="launch-1",
|
||||
requested_at=NOW,
|
||||
parameters={"title": "Permit application"},
|
||||
)
|
||||
result = ServiceLaunchResult(
|
||||
service_ref=service_ref,
|
||||
binding=binding,
|
||||
state="started",
|
||||
target_ref=_reference("case", "case-1"),
|
||||
href="/cases/case-1",
|
||||
metadata={"case_number": "PERMIT-1"},
|
||||
)
|
||||
|
||||
self.assertEqual("launch-1", request.idempotency_key)
|
||||
self.assertEqual("case-1", result.to_dict()["target_ref"]["object_id"])
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
|
||||
ServiceLaunchResult(
|
||||
service_ref=service_ref,
|
||||
binding=binding,
|
||||
state="started",
|
||||
target_ref=_reference("case", "case-2", tenant_id="tenant-2"),
|
||||
metadata={},
|
||||
)
|
||||
|
||||
def test_form_definition_contract_round_trips_exact_schema(self) -> None:
|
||||
definition = FormDefinition(
|
||||
reference=_reference("form", "permit-application"),
|
||||
key="permit-application",
|
||||
temporal=TemporalRevision(
|
||||
revision="3",
|
||||
recorded_at=NOW,
|
||||
change_reason="Add delivery preference.",
|
||||
),
|
||||
title="Permit application",
|
||||
description="Apply for a permit.",
|
||||
fields=(
|
||||
FormFieldDefinition(
|
||||
key="email",
|
||||
label="Email address",
|
||||
value_type="email",
|
||||
required=True,
|
||||
constraints={"max_length": 255},
|
||||
),
|
||||
FormFieldDefinition(
|
||||
key="delivery",
|
||||
label="Delivery",
|
||||
value_type="choice",
|
||||
options=("portal", "mail"),
|
||||
),
|
||||
),
|
||||
max_attachments=3,
|
||||
signature_requirement="optional",
|
||||
policy_refs=("policy:permit-intake",),
|
||||
handoff_kinds=("case",),
|
||||
)
|
||||
|
||||
restored = FormDefinition.from_mapping(definition.to_dict())
|
||||
|
||||
self.assertEqual("3", restored.reference.version)
|
||||
self.assertEqual("email", restored.fields[0].key)
|
||||
self.assertEqual(("portal", "mail"), restored.fields[1].options)
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "unique"):
|
||||
replace(restored, fields=(restored.fields[0], restored.fields[0]))
|
||||
|
||||
def test_service_launch_redirect_rejects_credentialed_urls(self) -> None:
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "credential-free"):
|
||||
ServiceLaunchResult(
|
||||
service_ref=_reference("service", "service-1"),
|
||||
binding=ServiceBinding("external", "external-entry"),
|
||||
state="redirect",
|
||||
href="https://user:secret@example.test/start",
|
||||
metadata={},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "must be a boolean"):
|
||||
ServiceBinding.from_mapping(
|
||||
{
|
||||
"kind": "workflow",
|
||||
"reference": "workflow:permit",
|
||||
"required": "false",
|
||||
}
|
||||
)
|
||||
|
||||
def test_service_derivation_can_only_tighten_parent_constraints(self) -> None:
|
||||
parent = ServiceDefinition(
|
||||
reference=_reference("service", "permit-template"),
|
||||
key="permit.apply",
|
||||
temporal=TemporalRevision(
|
||||
revision="7",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=10),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
title="Permit service",
|
||||
audience=("resident", "business"),
|
||||
prerequisites=("registered-address",),
|
||||
required_evidence_types=("application",),
|
||||
channels=("portal", "mail"),
|
||||
bindings=(ServiceBinding("case", "permit-case"),),
|
||||
availability_requirements=(
|
||||
ServiceAvailabilityRequirement("policy", "permit-access"),
|
||||
),
|
||||
publication_state="published",
|
||||
)
|
||||
temporal = TemporalRevision(
|
||||
revision="8",
|
||||
valid_from=NOW,
|
||||
valid_to=NOW + timedelta(days=5),
|
||||
recorded_at=NOW,
|
||||
change_reason="Tenant specialization.",
|
||||
)
|
||||
reference = replace(
|
||||
_reference("service", "permit-tenant"),
|
||||
version="8",
|
||||
)
|
||||
|
||||
derived = derive_service_restriction(
|
||||
parent,
|
||||
reference=reference,
|
||||
temporal=temporal,
|
||||
audience=("resident",),
|
||||
channels=("portal",),
|
||||
add_prerequisites=("tenant-residency",),
|
||||
add_required_evidence_types=("identity",),
|
||||
add_bindings=(ServiceBinding("workflow", "permit-review"),),
|
||||
)
|
||||
|
||||
self.assertEqual(parent.reference, derived.derived_from_ref)
|
||||
self.assertEqual(("resident",), derived.audience)
|
||||
self.assertEqual(("portal",), derived.channels)
|
||||
self.assertEqual(
|
||||
("registered-address", "tenant-residency"),
|
||||
derived.prerequisites,
|
||||
)
|
||||
self.assertIn(ServiceBinding("case", "permit-case"), derived.bindings)
|
||||
self.assertEqual(
|
||||
parent.availability_requirements,
|
||||
derived.availability_requirements,
|
||||
)
|
||||
self.assertEqual(
|
||||
parent.availability_requirements,
|
||||
ServiceDefinition.from_mapping(
|
||||
derived.to_dict()
|
||||
).availability_requirements,
|
||||
)
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "widen.*audience"):
|
||||
derive_service_restriction(
|
||||
parent,
|
||||
reference=reference,
|
||||
temporal=temporal,
|
||||
audience=("resident", "visitor"),
|
||||
)
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "beyond"):
|
||||
derive_service_restriction(
|
||||
parent,
|
||||
reference=reference,
|
||||
temporal=replace(temporal, valid_to=None),
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
InstitutionalContextError,
|
||||
"publication state",
|
||||
):
|
||||
derive_service_restriction(
|
||||
replace(parent, publication_state="suspended"),
|
||||
reference=reference,
|
||||
temporal=temporal,
|
||||
publication_state="published",
|
||||
)
|
||||
|
||||
def test_mandate_resolution_is_effective_scoped_and_deterministic(self) -> None:
|
||||
context = _context()
|
||||
mandate = MandateDefinition(
|
||||
reference=_reference("mandate", "mandate-1"),
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
task_types=("committee.formal_decision",),
|
||||
authority_types=("permit",),
|
||||
organization_unit_refs=(
|
||||
_reference("organization_unit", "unit-1"),
|
||||
),
|
||||
function_refs=(_reference("function", "function-1"),),
|
||||
jurisdiction_refs=context.jurisdiction_refs,
|
||||
subject_types=("permit",),
|
||||
evidence=context.evidence,
|
||||
)
|
||||
request = MandateResolutionRequest(
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
task_type="committee.formal_decision",
|
||||
authority_type="permit",
|
||||
organization_unit_ref=replace(
|
||||
_reference("organization_unit", "unit-1"), version="9"
|
||||
),
|
||||
function_ref=replace(
|
||||
_reference("function", "function-1"), version="9"
|
||||
),
|
||||
jurisdiction_refs=tuple(
|
||||
replace(item, version="9") for item in context.jurisdiction_refs
|
||||
),
|
||||
subject_type="permit",
|
||||
)
|
||||
|
||||
resolution = resolve_mandate_candidates(
|
||||
request,
|
||||
(
|
||||
replace(
|
||||
mandate,
|
||||
reference=_reference("mandate", "retired"),
|
||||
status="retired",
|
||||
),
|
||||
mandate,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(resolution.competent)
|
||||
self.assertEqual("mandate-1", resolution.mandates[0].reference.object_id)
|
||||
self.assertEqual("file-1", resolution.evidence[0].evidence_id)
|
||||
|
||||
ambiguous = resolve_mandate_candidates(
|
||||
request,
|
||||
(
|
||||
mandate,
|
||||
replace(
|
||||
mandate,
|
||||
reference=_reference("mandate", "mandate-2"),
|
||||
),
|
||||
),
|
||||
)
|
||||
self.assertFalse(ambiguous.competent)
|
||||
self.assertEqual(2, len(ambiguous.conflict_refs))
|
||||
|
||||
missing_jurisdiction = resolve_mandate_candidates(
|
||||
replace(request, jurisdiction_refs=()),
|
||||
(mandate,),
|
||||
)
|
||||
self.assertFalse(missing_jurisdiction.competent)
|
||||
|
||||
def test_mandate_resolution_rejects_cross_tenant_candidates(self) -> None:
|
||||
request = MandateResolutionRequest(
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
task_type="committee.formal_decision",
|
||||
authority_type="permit",
|
||||
)
|
||||
candidate = MandateDefinition(
|
||||
reference=_reference("mandate", "mandate-2", tenant_id="tenant-2"),
|
||||
temporal=TemporalRevision(revision="1", recorded_at=NOW),
|
||||
task_types=("committee.formal_decision",),
|
||||
authority_types=("permit",),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "cross tenants"):
|
||||
resolve_mandate_candidates(request, (candidate,))
|
||||
|
||||
def test_mandate_lifecycle_revisions_are_linked_by_stable_identity(self) -> None:
|
||||
current = MandateDefinition(
|
||||
reference=_reference("mandate", "mandate-1"),
|
||||
temporal=TemporalRevision(revision="1", recorded_at=NOW),
|
||||
task_types=("committee.formal_decision",),
|
||||
authority_types=("permit",),
|
||||
status="active",
|
||||
)
|
||||
temporal = TemporalRevision(
|
||||
revision="2",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Authority temporarily suspended.",
|
||||
)
|
||||
|
||||
suspended = revise_mandate_definition(
|
||||
current,
|
||||
expected_revision="1",
|
||||
temporal=temporal,
|
||||
status="suspended",
|
||||
suspension_reason="Delegation is under review.",
|
||||
)
|
||||
|
||||
self.assertEqual("active", current.status)
|
||||
self.assertEqual("suspended", suspended.status)
|
||||
self.assertEqual("2", suspended.reference.version)
|
||||
self.assertEqual(current.reference.object_id, suspended.reference.object_id)
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "stale"):
|
||||
revise_mandate_definition(
|
||||
current,
|
||||
expected_revision="0",
|
||||
temporal=temporal,
|
||||
status="suspended",
|
||||
suspension_reason="Review.",
|
||||
)
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "not allowed"):
|
||||
revise_mandate_definition(
|
||||
suspended,
|
||||
expected_revision="2",
|
||||
temporal=replace(
|
||||
temporal,
|
||||
revision="3",
|
||||
change_reason="Invalid reset.",
|
||||
),
|
||||
status="draft",
|
||||
)
|
||||
|
||||
def test_formal_decision_revisions_are_linked_and_occ_guarded(self) -> None:
|
||||
context = _context()
|
||||
reference = replace(
|
||||
_reference("decision", "decision-1"),
|
||||
version=context.temporal.revision,
|
||||
)
|
||||
context = replace(context, decision_ref=reference)
|
||||
current = FormalDecision(
|
||||
reference=reference,
|
||||
temporal=context.temporal,
|
||||
decision_type="permit",
|
||||
subject_refs=(_reference("case", "case-1"),),
|
||||
state="decided",
|
||||
authority_context=context,
|
||||
fact_evidence=context.evidence,
|
||||
legal_bases=context.legal_bases,
|
||||
operative_result="Permit granted.",
|
||||
reasoning="Protected reasoning.",
|
||||
requested_effects=(
|
||||
DecisionEffectReference("notify", "requested"),
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
InstitutionalContextError,
|
||||
"service account",
|
||||
):
|
||||
replace(current, assurance_level="automated_under_mandate")
|
||||
revision = TemporalRevision(
|
||||
revision="8",
|
||||
valid_from=NOW + timedelta(minutes=1),
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Decision entered into force.",
|
||||
)
|
||||
|
||||
effective = revise_formal_decision(
|
||||
current,
|
||||
expected_revision="7",
|
||||
temporal=revision,
|
||||
state="effective",
|
||||
)
|
||||
|
||||
self.assertEqual("decided", current.state)
|
||||
self.assertEqual("8", effective.reference.version)
|
||||
self.assertEqual("7", effective.supersedes_ref.version)
|
||||
self.assertEqual(
|
||||
effective.reference,
|
||||
effective.authority_context.decision_ref,
|
||||
)
|
||||
self.assertEqual(current.requested_effects, effective.requested_effects)
|
||||
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "stale"):
|
||||
revise_formal_decision(
|
||||
current,
|
||||
expected_revision="6",
|
||||
temporal=revision,
|
||||
state="effective",
|
||||
)
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "not allowed"):
|
||||
revise_formal_decision(
|
||||
effective,
|
||||
expected_revision="8",
|
||||
temporal=replace(
|
||||
revision,
|
||||
revision="9",
|
||||
change_reason="Invalid regression.",
|
||||
),
|
||||
state="proposed",
|
||||
)
|
||||
|
||||
corrected = revise_formal_decision(
|
||||
effective,
|
||||
expected_revision="8",
|
||||
temporal=replace(
|
||||
revision,
|
||||
revision="9",
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Corrected operative wording.",
|
||||
),
|
||||
state="corrected",
|
||||
operative_result="Permit granted subject to condition A.",
|
||||
)
|
||||
self.assertEqual(effective.reference, corrected.correction_of_ref)
|
||||
self.assertEqual(effective.reference, corrected.supersedes_ref)
|
||||
|
||||
def test_party_corrections_and_representation_revocation_are_versioned(self) -> None:
|
||||
context = _context()
|
||||
represented = _reference("party", "party-applicant")
|
||||
representative = _reference("party", "party-agent")
|
||||
representation = PartyRepresentation(
|
||||
representative_party_ref=representative,
|
||||
represented_party_ref=represented,
|
||||
power_ref="power-1",
|
||||
permitted_actions=("submit", "receive"),
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=1),
|
||||
),
|
||||
evidence=context.evidence,
|
||||
)
|
||||
current = ProcedureParty(
|
||||
reference=represented,
|
||||
procedure_ref=_reference("case", "case-1"),
|
||||
role="applicant",
|
||||
subject=PartySubjectReference(
|
||||
kind="identity",
|
||||
provider="identity",
|
||||
subject_id="identity-1",
|
||||
tenant_id="tenant-1",
|
||||
version="2",
|
||||
),
|
||||
temporal=TemporalRevision(revision="1", recorded_at=NOW),
|
||||
permitted_channels=("mail", "postbox"),
|
||||
preferred_channels=("mail",),
|
||||
delivery_recipient=True,
|
||||
representations=(representation,),
|
||||
contact_snapshot_refs=("addresses:snapshot-1",),
|
||||
evidence=context.evidence,
|
||||
)
|
||||
revision = TemporalRevision(
|
||||
revision="2",
|
||||
valid_from=NOW,
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Correct preferred delivery channel.",
|
||||
)
|
||||
|
||||
corrected = revise_procedure_party(
|
||||
current,
|
||||
expected_revision="1",
|
||||
temporal=revision,
|
||||
preferred_channels=("postbox",),
|
||||
)
|
||||
revoked_power = revoke_party_representation(
|
||||
representation,
|
||||
expected_revision="1",
|
||||
temporal=TemporalRevision(
|
||||
revision="2",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(minutes=2),
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Power was revoked.",
|
||||
),
|
||||
revoked_at=NOW + timedelta(minutes=2),
|
||||
)
|
||||
|
||||
self.assertEqual(("mail",), current.preferred_channels)
|
||||
self.assertEqual(("postbox",), corrected.preferred_channels)
|
||||
self.assertEqual("2", corrected.reference.version)
|
||||
self.assertIsNone(representation.revoked_at)
|
||||
self.assertEqual(NOW + timedelta(minutes=2), revoked_power.revoked_at)
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "stale"):
|
||||
revise_procedure_party(
|
||||
current,
|
||||
expected_revision="0",
|
||||
temporal=revision,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.db.migration_lock import (
|
||||
deployment_migration_lock,
|
||||
migration_lock_key,
|
||||
)
|
||||
|
||||
|
||||
class MigrationLockTests(unittest.TestCase):
|
||||
def test_lock_key_is_stable_and_namespaced(self) -> None:
|
||||
first = migration_lock_key("installation-a", "release")
|
||||
self.assertEqual(first, migration_lock_key("installation-a", "release"))
|
||||
self.assertNotEqual(first, migration_lock_key("installation-b", "release"))
|
||||
self.assertNotEqual(first, migration_lock_key("installation-a", "dev"))
|
||||
self.assertGreaterEqual(first, -(2**63))
|
||||
self.assertLess(first, 2**63)
|
||||
|
||||
def test_non_postgres_migration_lock_is_a_noop(self) -> None:
|
||||
entered = False
|
||||
with deployment_migration_lock(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
installation_id="installation-a",
|
||||
migration_track="release",
|
||||
):
|
||||
entered = True
|
||||
self.assertTrue(entered)
|
||||
|
||||
def test_postgres_lock_is_held_for_the_guarded_operation(self) -> None:
|
||||
result = MagicMock()
|
||||
result.scalar_one.return_value = True
|
||||
connection = MagicMock()
|
||||
connection.execute.return_value = result
|
||||
engine = MagicMock()
|
||||
engine.connect.return_value = connection
|
||||
with patch(
|
||||
"govoplan_core.db.migration_lock.create_engine",
|
||||
return_value=engine,
|
||||
):
|
||||
with deployment_migration_lock(
|
||||
"postgresql://user:secret@db.example.test/govoplan",
|
||||
installation_id="installation-a",
|
||||
migration_track="release",
|
||||
):
|
||||
self.assertEqual(1, connection.execute.call_count)
|
||||
|
||||
self.assertEqual(2, connection.execute.call_count)
|
||||
connection.close.assert_called_once_with()
|
||||
engine.dispose.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1261,6 +1261,41 @@ finally:
|
||||
self.assertIn("module_active", {issue.code for issue in preflight.issues})
|
||||
self.assertIn("module_desired", {issue.code for issue in preflight.issues})
|
||||
|
||||
def test_module_installer_blocks_node_local_package_mutation_in_shared_profile(
|
||||
self,
|
||||
) -> None:
|
||||
plan = ModuleInstallPlan(
|
||||
items=(
|
||||
ModuleInstallPlanItem(
|
||||
module_id="files",
|
||||
action="update",
|
||||
source="manual",
|
||||
python_package="govoplan-files",
|
||||
python_ref="govoplan-files==1.2.3",
|
||||
),
|
||||
)
|
||||
)
|
||||
with patch.dict(os.environ, {"GOVOPLAN_STATE_PROFILE": "shared"}):
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
available={
|
||||
"files": ModuleManifest(
|
||||
id="files",
|
||||
name="Files",
|
||||
version="1.2.2",
|
||||
)
|
||||
},
|
||||
current_enabled=("files",),
|
||||
desired_enabled=("files",),
|
||||
maintenance_mode=True,
|
||||
)
|
||||
|
||||
self.assertFalse(preflight.allowed)
|
||||
self.assertIn(
|
||||
"immutable_cluster_release_required",
|
||||
{issue.code for issue in preflight.issues},
|
||||
)
|
||||
|
||||
def test_module_installer_preflight_blocks_incompatible_manifest(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-compat-", dir=_TEST_ROOT))
|
||||
settings = _settings(root)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.object_storage import (
|
||||
LocalFilesystemStorageBackend,
|
||||
S3StorageBackend,
|
||||
StorageBackendError,
|
||||
StorageObjectMissing,
|
||||
normalize_storage_key,
|
||||
)
|
||||
|
||||
|
||||
class _S3Error(RuntimeError):
|
||||
def __init__(self, code: str, status: int) -> None:
|
||||
super().__init__(code)
|
||||
self.response = {
|
||||
"Error": {"Code": code},
|
||||
"ResponseMetadata": {"HTTPStatusCode": status},
|
||||
}
|
||||
|
||||
|
||||
class _FakeS3Client:
|
||||
def __init__(self) -> None:
|
||||
self.objects: dict[str, bytes] = {}
|
||||
self.head_error: Exception | None = None
|
||||
|
||||
def put_object(self, *, Key: str, Body: bytes, **_kwargs) -> None:
|
||||
self.objects[Key] = Body
|
||||
|
||||
def get_object(self, *, Key: str, **_kwargs):
|
||||
try:
|
||||
payload = self.objects[Key]
|
||||
except KeyError as exc:
|
||||
raise _S3Error("NoSuchKey", 404) from exc
|
||||
return {"Body": BytesIO(payload), "ContentLength": len(payload)}
|
||||
|
||||
def head_object(self, *, Key: str, **_kwargs):
|
||||
if self.head_error is not None:
|
||||
raise self.head_error
|
||||
try:
|
||||
payload = self.objects[Key]
|
||||
except KeyError as exc:
|
||||
raise _S3Error("NotFound", 404) from exc
|
||||
return {"ContentLength": len(payload)}
|
||||
|
||||
def delete_object(self, *, Key: str, **_kwargs) -> None:
|
||||
self.objects.pop(Key, None)
|
||||
|
||||
|
||||
class ObjectStorageTests(unittest.TestCase):
|
||||
def test_local_backend_round_trip_pagination_and_safe_keys(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-object-store-") as directory:
|
||||
backend = LocalFilesystemStorageBackend(Path(directory))
|
||||
backend.put_bytes("campaign/a.eml", b"a")
|
||||
backend.put_bytes("campaign/b.eml", b"bb")
|
||||
|
||||
first = backend.list_objects(prefix="campaign/", limit=1)
|
||||
second = backend.list_objects(
|
||||
prefix="campaign/",
|
||||
after=first.next_cursor,
|
||||
limit=1,
|
||||
)
|
||||
|
||||
self.assertEqual(b"a", backend.get_bytes("campaign/a.eml"))
|
||||
self.assertEqual(1, backend.stat("campaign/a.eml").size_bytes)
|
||||
self.assertEqual(
|
||||
("campaign/a.eml",), tuple(item.key for item in first.objects)
|
||||
)
|
||||
self.assertEqual("campaign/a.eml", first.next_cursor)
|
||||
self.assertEqual(
|
||||
("campaign/b.eml",), tuple(item.key for item in second.objects)
|
||||
)
|
||||
self.assertIsNone(second.next_cursor)
|
||||
with self.assertRaises(StorageBackendError):
|
||||
normalize_storage_key("../outside")
|
||||
with self.assertRaises(StorageObjectMissing):
|
||||
backend.get_bytes("campaign/missing.eml")
|
||||
|
||||
def test_local_listing_is_globally_sorted_and_ignores_symlinks(self) -> None:
|
||||
with (
|
||||
tempfile.TemporaryDirectory(prefix="govoplan-object-store-") as directory,
|
||||
tempfile.TemporaryDirectory(prefix="govoplan-object-outside-") as outside,
|
||||
):
|
||||
root = Path(directory)
|
||||
backend = LocalFilesystemStorageBackend(root)
|
||||
backend.put_bytes("a/x.txt", b"nested")
|
||||
backend.put_bytes("a.txt", b"sibling")
|
||||
backend.put_bytes("b.txt", b"last")
|
||||
outside_file = Path(outside) / "secret.txt"
|
||||
outside_file.write_bytes(b"outside")
|
||||
(root / "linked-file.txt").symlink_to(outside_file)
|
||||
(root / "linked-directory").symlink_to(
|
||||
Path(outside), target_is_directory=True
|
||||
)
|
||||
|
||||
first = backend.list_objects(prefix="", limit=2)
|
||||
second = backend.list_objects(
|
||||
prefix="",
|
||||
after=first.next_cursor,
|
||||
limit=2,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
("a.txt", "a/x.txt"),
|
||||
tuple(item.key for item in first.objects),
|
||||
)
|
||||
self.assertEqual("a/x.txt", first.next_cursor)
|
||||
self.assertEqual(
|
||||
("b.txt",),
|
||||
tuple(item.key for item in second.objects),
|
||||
)
|
||||
self.assertIsNone(second.next_cursor)
|
||||
with self.assertRaises(StorageBackendError):
|
||||
backend.exists("../outside")
|
||||
|
||||
def test_s3_backend_distinguishes_missing_objects_from_backend_failure(
|
||||
self,
|
||||
) -> None:
|
||||
backend = S3StorageBackend(
|
||||
bucket="files",
|
||||
endpoint_url="http://garage:3900",
|
||||
region_name="garage",
|
||||
access_key_id="key",
|
||||
secret_access_key="secret",
|
||||
deployment_managed=True,
|
||||
)
|
||||
client = _FakeS3Client()
|
||||
backend._client = client
|
||||
|
||||
backend.put_bytes("campaign/message.eml", b"message/rfc822")
|
||||
self.assertTrue(backend.exists("campaign/message.eml"))
|
||||
self.assertEqual(b"message/rfc822", backend.get_bytes("campaign/message.eml"))
|
||||
self.assertFalse(backend.exists("campaign/missing.eml"))
|
||||
|
||||
client.head_error = _S3Error("AccessDenied", 403)
|
||||
with self.assertRaises(StorageBackendError):
|
||||
backend.exists("campaign/message.eml")
|
||||
|
||||
def test_external_s3_requires_explicit_https_deployment_trust(self) -> None:
|
||||
trusted = S3StorageBackend(
|
||||
bucket="files",
|
||||
endpoint_url="https://s3.internal.example.test",
|
||||
region_name="eu-test-1",
|
||||
access_key_id="key",
|
||||
secret_access_key="secret",
|
||||
endpoint_trusted=True,
|
||||
)
|
||||
trusted._client = _FakeS3Client()
|
||||
trusted.put_bytes("files/document.txt", b"document")
|
||||
self.assertEqual(b"document", trusted.get_bytes("files/document.txt"))
|
||||
|
||||
rejected = S3StorageBackend(
|
||||
bucket="files",
|
||||
endpoint_url="http://s3.internal.example.test",
|
||||
region_name="eu-test-1",
|
||||
access_key_id="key",
|
||||
secret_access_key="secret",
|
||||
endpoint_trusted=True,
|
||||
)
|
||||
with self.assertRaisesRegex(StorageBackendError, "HTTPS origin"):
|
||||
rejected.client
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,536 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ArchitectureDeclarationError,
|
||||
ExternalProviderDeclaration,
|
||||
ExternalProviderRuntimeState,
|
||||
ExternalProviderStateContext,
|
||||
ExternalProviderStateProviderRegistration,
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
ModuleMaturityEvidence,
|
||||
ProviderBehaviorDeclaration,
|
||||
ProviderObjectDeclaration,
|
||||
collect_external_provider_states,
|
||||
declared_module_architecture,
|
||||
external_provider_from_mapping,
|
||||
module_architecture_from_mapping,
|
||||
module_architecture_issues,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
|
||||
|
||||
def _architecture(
|
||||
*,
|
||||
providers: tuple[str, ...] = (),
|
||||
modes: tuple[str, ...] = ("external_mirror",),
|
||||
) -> ModuleArchitectureDeclaration:
|
||||
return ModuleArchitectureDeclaration(
|
||||
layer="data_reporting_integration",
|
||||
kind="integration",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_provider.py",
|
||||
summary="Exercises the bounded provider contract.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/PROVIDER.md",
|
||||
summary="Documents authority and outage behavior.",
|
||||
),
|
||||
),
|
||||
known_limits=("Only bounded test objects are supported.",),
|
||||
supported_authority_modes=modes,
|
||||
owned_concepts=("external transport",),
|
||||
non_owned_concepts=("domain records",),
|
||||
target_tested_providers=providers,
|
||||
)
|
||||
|
||||
|
||||
def _mirror_provider() -> ExternalProviderDeclaration:
|
||||
return ExternalProviderDeclaration(
|
||||
id="connectors.read_mirror",
|
||||
module_id="connectors",
|
||||
label="Read-only mirror",
|
||||
maturity="read",
|
||||
operations=("discover", "search", "read", "preview"),
|
||||
objects=(
|
||||
ProviderObjectDeclaration(
|
||||
object_type="register_entry",
|
||||
field_groups=("identity", "content", "source_metadata"),
|
||||
authority_modes=("external_authoritative", "external_mirror"),
|
||||
default_authority_mode="external_mirror",
|
||||
),
|
||||
),
|
||||
behavior=ProviderBehaviorDeclaration(
|
||||
revision_tokens="ETag and source revision are retained.",
|
||||
concurrency="Reads can require an expected source revision.",
|
||||
freshness="The latest successful acquisition time is reported.",
|
||||
health="Transport and parsing health are reported separately.",
|
||||
max_read_items=500,
|
||||
evidence="Immutable snapshots retain acquisition provenance.",
|
||||
correction="Refresh creates a new snapshot; old evidence is retained.",
|
||||
reconciliation="Compare the latest source revision and content digest.",
|
||||
outage="The last governed snapshot remains readable as stale.",
|
||||
classifications=("internal",),
|
||||
purposes=("governed import",),
|
||||
retention="Owning datasource retention policy applies.",
|
||||
secret_handling="Credentials stay in credential envelopes.",
|
||||
),
|
||||
capability_names=("connectors.readMirror",),
|
||||
)
|
||||
|
||||
|
||||
def _writable_provider() -> ExternalProviderDeclaration:
|
||||
return ExternalProviderDeclaration(
|
||||
id="calendar.caldav_sync",
|
||||
module_id="calendar",
|
||||
label="CalDAV synchronization",
|
||||
maturity="synchronize",
|
||||
operations=("discover", "read", "write", "delete", "synchronize", "preview"),
|
||||
objects=(
|
||||
ProviderObjectDeclaration(
|
||||
object_type="calendar_event",
|
||||
field_groups=("identity", "schedule", "participants", "recurrence"),
|
||||
authority_modes=("external_authoritative", "governed_sync"),
|
||||
default_authority_mode="governed_sync",
|
||||
),
|
||||
),
|
||||
behavior=ProviderBehaviorDeclaration(
|
||||
revision_tokens="ETag and sync token.",
|
||||
concurrency="Conditional requests reject stale ETags.",
|
||||
freshness="Last successful sync and pending changes are reported.",
|
||||
health="Discovery, authentication, and collection health are separate.",
|
||||
max_read_items=1000,
|
||||
idempotency="Stable UID and operation keys suppress duplicate effects.",
|
||||
retry="Bounded retry is limited to classified transient failures.",
|
||||
timeout_seconds=30,
|
||||
conflicts="Conflicts remain explicit until policy or a user resolves them.",
|
||||
outcome_unknown="Timed-out writes are outcome-unknown, never blindly retried.",
|
||||
outcome_unknown_supported=True,
|
||||
evidence="Request, response token, and reconciliation evidence are retained.",
|
||||
audit_event_types=("calendar.sync.requested", "calendar.sync.reconciled"),
|
||||
correction="A later governed write or tombstone corrects remote state.",
|
||||
rollback="Remote writes are not assumed rollback-safe.",
|
||||
compensation="A compensating update can be requested after reconciliation.",
|
||||
reconciliation="Read by UID and compare ETag before retrying.",
|
||||
outage="Local state remains marked stale and pending effects stay queued.",
|
||||
classifications=("confidential",),
|
||||
purposes=("calendar synchronization",),
|
||||
retention="Calendar and audit retention policies apply independently.",
|
||||
secret_handling="Credentials are referenced through credential envelopes.",
|
||||
),
|
||||
capability_names=("calendar.sync",),
|
||||
)
|
||||
|
||||
|
||||
def _state_registration(
|
||||
*,
|
||||
module_id: str,
|
||||
provider_id: str,
|
||||
) -> ExternalProviderStateProviderRegistration:
|
||||
return ExternalProviderStateProviderRegistration(
|
||||
module_id=module_id,
|
||||
provider_id=provider_id,
|
||||
provider=lambda _context: (),
|
||||
)
|
||||
|
||||
|
||||
class ProviderGovernanceContractTests(unittest.TestCase):
|
||||
def test_concise_declaration_keeps_repository_evidence_explicit(self) -> None:
|
||||
architecture = declared_module_architecture(
|
||||
layer="human_work_procedure",
|
||||
kind="domain",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/DOMAIN.md",
|
||||
test_ref="tests/test_service.py",
|
||||
known_limits=("Only the bounded service slice is implemented.",),
|
||||
owned_concepts=("domain record",),
|
||||
non_owned_concepts=("identity",),
|
||||
)
|
||||
|
||||
self.assertEqual("vertical_slice", architecture.maturity)
|
||||
self.assertEqual(
|
||||
{"documentation", "test"},
|
||||
{item.kind for item in architecture.evidence},
|
||||
)
|
||||
self.assertEqual(("domain record",), architecture.owned_concepts)
|
||||
|
||||
def test_declarations_round_trip_through_catalog_mappings(self) -> None:
|
||||
provider = _writable_provider()
|
||||
architecture = _architecture(
|
||||
providers=(provider.id,),
|
||||
modes=("external_authoritative", "governed_sync"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
architecture,
|
||||
module_architecture_from_mapping(architecture.to_dict()),
|
||||
)
|
||||
self.assertEqual(
|
||||
provider,
|
||||
external_provider_from_mapping(provider.to_dict()),
|
||||
)
|
||||
|
||||
def test_read_only_mirror_declaration_is_registry_validated(self) -> None:
|
||||
provider = _mirror_provider()
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="connectors",
|
||||
name="Connectors",
|
||||
version="1.0.0",
|
||||
architecture=_architecture(
|
||||
providers=(provider.id,),
|
||||
modes=("external_authoritative", "external_mirror"),
|
||||
),
|
||||
external_providers=(provider,),
|
||||
external_provider_state_providers=(
|
||||
_state_registration(
|
||||
module_id="connectors",
|
||||
provider_id=provider.id,
|
||||
),
|
||||
),
|
||||
capability_factories={"connectors.readMirror": lambda _context: object()},
|
||||
)
|
||||
)
|
||||
|
||||
snapshot = registry.validate()
|
||||
|
||||
self.assertEqual("connectors", snapshot.manifests[0].id)
|
||||
self.assertEqual((provider,), registry.external_provider_declarations())
|
||||
|
||||
def test_writable_outcome_unknown_provider_is_explicit(self) -> None:
|
||||
provider = _writable_provider()
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="calendar",
|
||||
name="Calendar",
|
||||
version="1.0.0",
|
||||
architecture=_architecture(
|
||||
providers=(provider.id,),
|
||||
modes=("external_authoritative", "governed_sync"),
|
||||
),
|
||||
external_providers=(provider,),
|
||||
external_provider_state_providers=(
|
||||
_state_registration(
|
||||
module_id="calendar",
|
||||
provider_id=provider.id,
|
||||
),
|
||||
),
|
||||
capability_factories={"calendar.sync": lambda _context: object()},
|
||||
)
|
||||
)
|
||||
|
||||
registry.validate()
|
||||
|
||||
payload = provider.to_dict()
|
||||
self.assertTrue(payload["behavior"]["outcome_unknown_supported"])
|
||||
self.assertIn("reconciliation", payload["behavior"])
|
||||
|
||||
def test_provider_requires_a_sanitized_runtime_state_registration(self) -> None:
|
||||
provider = _mirror_provider()
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="connectors",
|
||||
name="Connectors",
|
||||
version="1.0.0",
|
||||
architecture=_architecture(
|
||||
providers=(provider.id,),
|
||||
modes=("external_authoritative", "external_mirror"),
|
||||
),
|
||||
external_providers=(provider,),
|
||||
capability_factories={
|
||||
"connectors.readMirror": lambda _context: object()
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RegistryError,
|
||||
"require sanitized runtime state providers",
|
||||
):
|
||||
registry.validate()
|
||||
|
||||
def test_runtime_binding_states_are_validated_and_aggregated(self) -> None:
|
||||
observed_at = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
||||
|
||||
def states(_context: ExternalProviderStateContext):
|
||||
return (
|
||||
ExternalProviderRuntimeState(
|
||||
provider_id="calendar.caldav_sync",
|
||||
binding_ref="calendar:sync-source:one",
|
||||
authority_mode="governed_sync",
|
||||
observed_at=observed_at,
|
||||
configured=True,
|
||||
active=True,
|
||||
health="healthy",
|
||||
freshness="current",
|
||||
conflict="clear",
|
||||
recovery="ready",
|
||||
),
|
||||
ExternalProviderRuntimeState(
|
||||
provider_id="calendar.caldav_sync",
|
||||
binding_ref="calendar:sync-source:two",
|
||||
authority_mode="external_mirror",
|
||||
observed_at=observed_at,
|
||||
configured=True,
|
||||
active=True,
|
||||
health="warning",
|
||||
freshness="stale",
|
||||
conflict="pending",
|
||||
recovery="attention",
|
||||
),
|
||||
)
|
||||
|
||||
payload = collect_external_provider_states(
|
||||
(
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id="calendar",
|
||||
provider_id="calendar.caldav_sync",
|
||||
provider=states,
|
||||
),
|
||||
),
|
||||
ExternalProviderStateContext(session=None, tenant_id="tenant-1"),
|
||||
)["calendar.caldav_sync"]
|
||||
|
||||
self.assertEqual("warning", payload["health"])
|
||||
self.assertEqual("stale", payload["freshness"])
|
||||
self.assertEqual("pending", payload["conflict"])
|
||||
self.assertEqual("attention", payload["recovery"])
|
||||
self.assertEqual(2, len(payload["bindings"]))
|
||||
|
||||
def test_runtime_state_registration_requires_declared_provider(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="calendar",
|
||||
name="Calendar",
|
||||
version="1.0.0",
|
||||
architecture=_architecture(),
|
||||
external_provider_state_providers=(
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id="calendar",
|
||||
provider_id="calendar.caldav_sync",
|
||||
provider=lambda _context: (),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "undeclared provider"):
|
||||
registry.validate()
|
||||
|
||||
def test_runtime_state_failure_is_sanitized(self) -> None:
|
||||
def fail(_context: ExternalProviderStateContext):
|
||||
raise RuntimeError("secret transport detail")
|
||||
|
||||
payload = collect_external_provider_states(
|
||||
(
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id="calendar",
|
||||
provider_id="calendar.caldav_sync",
|
||||
provider=fail,
|
||||
),
|
||||
),
|
||||
ExternalProviderStateContext(session=None),
|
||||
)["calendar.caldav_sync"]
|
||||
|
||||
self.assertEqual("error", payload["health"])
|
||||
self.assertNotIn("secret transport detail", str(payload))
|
||||
|
||||
def test_supported_claim_requires_evidence_not_only_a_string(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
architecture=ModuleArchitectureDeclaration(
|
||||
layer="domain_capability",
|
||||
kind="domain",
|
||||
maturity="supported",
|
||||
evidence=(),
|
||||
owned_concepts=("example",),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "missing evidence"):
|
||||
registry.validate()
|
||||
|
||||
def test_supported_and_lts_claims_require_reference_packages(self) -> None:
|
||||
for maturity in ("supported", "lts"):
|
||||
with self.subTest(maturity=maturity):
|
||||
declaration = ModuleArchitectureDeclaration(
|
||||
layer="domain_capability",
|
||||
kind="domain",
|
||||
maturity=maturity,
|
||||
evidence=tuple(
|
||||
ModuleMaturityEvidence(
|
||||
kind=kind,
|
||||
reference=f"evidence/{kind}.json",
|
||||
summary=f"{kind} evidence.",
|
||||
)
|
||||
for kind in (
|
||||
"test",
|
||||
"documentation",
|
||||
"reference_process",
|
||||
"target_test",
|
||||
"recovery",
|
||||
"security",
|
||||
"operations",
|
||||
"accessibility",
|
||||
"privacy",
|
||||
"upgrade",
|
||||
*(("compatibility",) if maturity == "lts" else ()),
|
||||
)
|
||||
),
|
||||
owned_concepts=("example",),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
upgrade=("docs/UPGRADE.md",),
|
||||
recovery=("docs/RECOVERY.md",),
|
||||
security=("docs/SECURITY.md",),
|
||||
operations=("docs/OPERATIONS.md",),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
(f"{maturity} maturity requires a reference package",),
|
||||
module_architecture_issues(declaration, has_migrations=False),
|
||||
)
|
||||
|
||||
def test_reference_ready_claim_requires_target_human_and_operator_evidence(self) -> None:
|
||||
declaration = ModuleArchitectureDeclaration(
|
||||
layer="domain_capability",
|
||||
kind="domain",
|
||||
maturity="reference_ready",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_reference.py",
|
||||
summary="Automated journey proof.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/REFERENCE.md",
|
||||
summary="Reference composition documentation.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="reference_process",
|
||||
reference="docs/JOURNEY.md",
|
||||
summary="Reference process definition.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="recovery",
|
||||
reference="evidence/recovery.json",
|
||||
summary="Recovery drill evidence.",
|
||||
),
|
||||
),
|
||||
known_limits=("One provider profile remains bounded.",),
|
||||
owned_concepts=("example",),
|
||||
reference_packages=("reference.example",),
|
||||
)
|
||||
|
||||
issues = module_architecture_issues(declaration, has_migrations=False)
|
||||
|
||||
self.assertIn("accessibility", issues[0])
|
||||
self.assertIn("operations", issues[0])
|
||||
self.assertIn("privacy", issues[0])
|
||||
self.assertIn("security", issues[0])
|
||||
self.assertIn("target_test", issues[0])
|
||||
self.assertIn("missing documentation", issues[1])
|
||||
|
||||
def test_target_tested_provider_requires_provider_evidence(self) -> None:
|
||||
evidence = tuple(
|
||||
ModuleMaturityEvidence(
|
||||
kind=kind,
|
||||
reference=f"evidence/{kind}.json",
|
||||
summary=f"{kind} evidence.",
|
||||
)
|
||||
for kind in (
|
||||
"test",
|
||||
"documentation",
|
||||
"reference_process",
|
||||
"target_test",
|
||||
"recovery",
|
||||
"security",
|
||||
"operations",
|
||||
"accessibility",
|
||||
"privacy",
|
||||
)
|
||||
)
|
||||
declaration = ModuleArchitectureDeclaration(
|
||||
layer="data_reporting_integration",
|
||||
kind="integration",
|
||||
maturity="reference_ready",
|
||||
evidence=evidence,
|
||||
known_limits=("One bounded target profile is assessed.",),
|
||||
reference_packages=("reference.integration",),
|
||||
target_tested_providers=("connectors.example",),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
recovery=("docs/RECOVERY.md",),
|
||||
security=("docs/SECURITY.md",),
|
||||
operations=("docs/OPERATIONS.md",),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
(
|
||||
"maturity 'reference_ready' is missing evidence: provider",
|
||||
),
|
||||
module_architecture_issues(declaration, has_migrations=False),
|
||||
)
|
||||
|
||||
def test_provider_references_must_exist_in_manifest(self) -> None:
|
||||
provider = _mirror_provider()
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="connectors",
|
||||
name="Connectors",
|
||||
version="1.0.0",
|
||||
architecture=_architecture(
|
||||
providers=(provider.id,),
|
||||
modes=("external_authoritative", "external_mirror"),
|
||||
),
|
||||
external_providers=(provider,),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "undeclared capabilities"):
|
||||
registry.validate()
|
||||
|
||||
def test_governed_sync_requires_read_and_write_semantics(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ArchitectureDeclarationError,
|
||||
"read and write/synchronize",
|
||||
):
|
||||
ExternalProviderDeclaration(
|
||||
id="broken.sync",
|
||||
module_id="broken",
|
||||
label="Broken sync",
|
||||
maturity="synchronize",
|
||||
operations=("synchronize",),
|
||||
objects=(
|
||||
ProviderObjectDeclaration(
|
||||
object_type="object",
|
||||
field_groups=("content",),
|
||||
authority_modes=("governed_sync",),
|
||||
default_authority_mode="governed_sync",
|
||||
),
|
||||
),
|
||||
behavior=_writable_provider().behavior,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryCheckpoint,
|
||||
RecoveryGuaranteeError,
|
||||
RecoveryMode,
|
||||
RecoveryOperation,
|
||||
RecoveryPlan,
|
||||
RecoveryStatus,
|
||||
plan_recovery_operation,
|
||||
prepare_recovery_operation,
|
||||
record_recovery_checkpoint,
|
||||
start_recovery_operation,
|
||||
transition_recovery_operation,
|
||||
verify_recovery_evidence_chain,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import DistributedLease, acquire_lease
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _session() -> tuple[Session, object]:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
DistributedLease.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
],
|
||||
)
|
||||
return Session(engine, expire_on_commit=False), engine
|
||||
|
||||
|
||||
def test_recovery_plan_requires_mode_specific_evidence() -> None:
|
||||
with pytest.raises(RecoveryGuaranteeError, match="compensation steps"):
|
||||
RecoveryPlan(
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
verification_steps=("verify",),
|
||||
).validate()
|
||||
with pytest.raises(RecoveryGuaranteeError, match="backup reference"):
|
||||
RecoveryPlan(
|
||||
mode=RecoveryMode.SNAPSHOT_RESTORE,
|
||||
verification_steps=("verify",),
|
||||
).validate()
|
||||
with pytest.raises(RecoveryGuaranteeError, match="approval reference"):
|
||||
RecoveryPlan(
|
||||
mode=RecoveryMode.IRREVERSIBLE,
|
||||
verification_steps=("verify",),
|
||||
).validate()
|
||||
|
||||
|
||||
def test_recovery_plan_hashes_backup_and_approval_references() -> None:
|
||||
plan = RecoveryPlan(
|
||||
mode=RecoveryMode.SNAPSHOT_RESTORE,
|
||||
verification_steps=("restore drill passed",),
|
||||
backup_reference="backup://postgres/2026-08-01",
|
||||
approval_reference="approval://change/42",
|
||||
)
|
||||
|
||||
assert plan.as_dict()["backup_reference"] == "backup://postgres/2026-08-01"
|
||||
assert plan.as_dict()["approval_reference"] == "approval://change/42"
|
||||
|
||||
|
||||
def test_recovery_operation_records_verifiable_checkpoint_chain() -> None:
|
||||
session, engine = _session()
|
||||
try:
|
||||
operation = plan_recovery_operation(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
module_id="files",
|
||||
operation_type="move-tree",
|
||||
idempotency_key="move-42",
|
||||
request={"source": "a", "target": "b"},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
compensation_steps=("restore source links",),
|
||||
verification_steps=("compare object inventory",),
|
||||
),
|
||||
)
|
||||
prepare_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
evidence={"inventory_sha256": "a" * 64},
|
||||
)
|
||||
start_recovery_operation(session, operation)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
kind="side-effect-failed",
|
||||
summary="Target index update failed",
|
||||
evidence={"completed_steps": 1},
|
||||
failure_summary="target index unavailable",
|
||||
)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERING,
|
||||
kind="compensation-started",
|
||||
summary="Compensation started",
|
||||
)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERED,
|
||||
kind="compensation-verified",
|
||||
summary="Source inventory restored",
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {"object_inventory": "matched"},
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert operation.status == RecoveryStatus.RECOVERED.value
|
||||
assert operation.checkpoint_count == 6
|
||||
assert verify_recovery_evidence_chain(session, operation.id) is True
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_recovery_transition_cannot_skip_preparation() -> None:
|
||||
session, engine = _session()
|
||||
try:
|
||||
operation = plan_recovery_operation(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
module_id="core",
|
||||
operation_type="configuration-change",
|
||||
idempotency_key="config-1",
|
||||
request={"revision": 2},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.ATOMIC,
|
||||
verification_steps=("read current revision",),
|
||||
),
|
||||
)
|
||||
with pytest.raises(RecoveryGuaranteeError, match="not allowed"):
|
||||
start_recovery_operation(session, operation)
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_non_atomic_failure_requires_explicit_recovery_and_verified_completion() -> (
|
||||
None
|
||||
):
|
||||
session, engine = _session()
|
||||
try:
|
||||
operation = plan_recovery_operation(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
module_id="campaign",
|
||||
operation_type="artifact-publish",
|
||||
idempotency_key="publish-1",
|
||||
request={"version": "version-1"},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
compensation_steps=("delete published objects",),
|
||||
verification_steps=("verify object inventory",),
|
||||
),
|
||||
)
|
||||
prepare_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
evidence={"preconditions": "verified"},
|
||||
)
|
||||
start_recovery_operation(session, operation)
|
||||
|
||||
with pytest.raises(RecoveryGuaranteeError, match="cannot be marked failed"):
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.FAILED,
|
||||
kind="failed",
|
||||
summary="Object publication failed",
|
||||
)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
kind="recovery-required",
|
||||
summary="Published objects require compensation",
|
||||
)
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERING,
|
||||
kind="compensation-started",
|
||||
summary="Removing published objects",
|
||||
)
|
||||
with pytest.raises(RecoveryGuaranteeError, match="verified evidence"):
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.RECOVERED,
|
||||
kind="compensation-finished",
|
||||
summary="Objects removed",
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_recovery_evidence_rejects_plaintext_secrets() -> None:
|
||||
session, engine = _session()
|
||||
try:
|
||||
with pytest.raises(RecoveryGuaranteeError, match="plaintext secrets"):
|
||||
plan_recovery_operation(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
module_id="mail",
|
||||
operation_type="transport-change",
|
||||
idempotency_key="transport-1",
|
||||
request={"transport_id": "smtp-1"},
|
||||
metadata={"password": "do-not-store"},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.ATOMIC,
|
||||
verification_steps=("read persisted transport",),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_fenced_recovery_rejects_low_level_mutation_without_claim() -> None:
|
||||
session, engine = _session()
|
||||
try:
|
||||
claim = acquire_lease(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
resource_key="files:move-tree",
|
||||
holder_node_id="worker-1",
|
||||
holder_incarnation="worker-1-incarnation",
|
||||
ttl_seconds=300,
|
||||
now=datetime.now(timezone.utc),
|
||||
)
|
||||
assert claim is not None
|
||||
operation = plan_recovery_operation(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
module_id="files",
|
||||
operation_type="move-tree",
|
||||
idempotency_key="move-fenced-1",
|
||||
request={"source": "a", "target": "b"},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
compensation_steps=("restore source links",),
|
||||
verification_steps=("compare object inventory",),
|
||||
),
|
||||
lease_claim=claim,
|
||||
)
|
||||
|
||||
with pytest.raises(RecoveryGuaranteeError, match="distributed lease fence"):
|
||||
transition_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
status=RecoveryStatus.PREPARED,
|
||||
kind="prepared",
|
||||
summary="Attempted without fence",
|
||||
evidence={"inventory_sha256": "a" * 64},
|
||||
)
|
||||
with pytest.raises(RecoveryGuaranteeError, match="distributed lease fence"):
|
||||
record_recovery_checkpoint(
|
||||
session,
|
||||
operation,
|
||||
kind="manual-note",
|
||||
summary="Attempted without fence",
|
||||
evidence={},
|
||||
)
|
||||
|
||||
prepared = prepare_recovery_operation(
|
||||
session,
|
||||
operation,
|
||||
evidence={"inventory_sha256": "a" * 64},
|
||||
lease_claim=claim,
|
||||
)
|
||||
assert prepared.status == RecoveryStatus.PREPARED.value
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
RuntimeCoordinationError,
|
||||
RuntimeIdentity,
|
||||
)
|
||||
from govoplan_core.server.runtime_agent import RuntimeNodeAgent
|
||||
|
||||
|
||||
class _Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args) -> None:
|
||||
return None
|
||||
|
||||
def commit(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Database:
|
||||
def SessionLocal(self) -> _Session:
|
||||
return _Session()
|
||||
|
||||
|
||||
class _Consumer:
|
||||
def __init__(self) -> None:
|
||||
self.cancelled: list[str] = []
|
||||
self.added: list[str] = []
|
||||
|
||||
def cancel_task_queue(self, queue: str) -> None:
|
||||
self.cancelled.append(queue)
|
||||
|
||||
def add_task_queue(self, queue: str) -> None:
|
||||
self.added.append(queue)
|
||||
|
||||
|
||||
def test_api_runtime_agent_fails_readiness_on_heartbeat_error() -> None:
|
||||
agent = RuntimeNodeAgent(
|
||||
settings=SimpleNamespace(
|
||||
installation_id="installation-1",
|
||||
runtime_role="api",
|
||||
runtime_node_id="api-1",
|
||||
runtime_heartbeat_seconds=15,
|
||||
celery_queues="",
|
||||
),
|
||||
software_version="0.1.14",
|
||||
module_ids=("core",),
|
||||
)
|
||||
agent.coordination_healthy = True
|
||||
|
||||
def fail() -> None:
|
||||
raise RuntimeCoordinationError("database unavailable")
|
||||
|
||||
agent._heartbeat = fail
|
||||
assert asyncio.run(agent._heartbeat_once()) is False
|
||||
assert agent.coordination_healthy is False
|
||||
|
||||
agent._heartbeat = lambda: None
|
||||
assert asyncio.run(agent._heartbeat_once()) is True
|
||||
assert agent.coordination_healthy is True
|
||||
|
||||
|
||||
def test_worker_disables_consumers_without_reclaiming_stale_identity(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from govoplan_core import celery_app
|
||||
|
||||
identity = RuntimeIdentity(
|
||||
installation_id="installation-1",
|
||||
node_id="worker-1",
|
||||
incarnation="stale-incarnation",
|
||||
role="worker",
|
||||
software_version="0.1.14",
|
||||
composition_hash="a" * 64,
|
||||
queues=("default", "mail"),
|
||||
)
|
||||
consumer = _Consumer()
|
||||
original_state = (
|
||||
celery_app._worker_identity,
|
||||
celery_app._worker_consumer,
|
||||
celery_app._worker_draining,
|
||||
)
|
||||
celery_app._worker_identity = identity
|
||||
celery_app._worker_consumer = consumer
|
||||
celery_app._worker_draining = False
|
||||
monkeypatch.setattr(celery_app, "get_database", lambda: _Database())
|
||||
|
||||
def reject_heartbeat(*_args, **_kwargs):
|
||||
raise RuntimeCoordinationError("stale node incarnation")
|
||||
|
||||
monkeypatch.setattr(
|
||||
celery_app,
|
||||
"heartbeat_runtime_node",
|
||||
reject_heartbeat,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
celery_app,
|
||||
"register_runtime_node",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("heartbeat failures must not reclaim an identity")
|
||||
),
|
||||
)
|
||||
try:
|
||||
celery_app._heartbeat_worker_runtime()
|
||||
assert consumer.cancelled == ["default", "mail"]
|
||||
assert celery_app._worker_draining is True
|
||||
|
||||
monkeypatch.setattr(
|
||||
celery_app,
|
||||
"heartbeat_runtime_node",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(state="active"),
|
||||
)
|
||||
celery_app._heartbeat_worker_runtime()
|
||||
assert consumer.added == ["default", "mail"]
|
||||
assert celery_app._worker_draining is False
|
||||
finally:
|
||||
(
|
||||
celery_app._worker_identity,
|
||||
celery_app._worker_consumer,
|
||||
celery_app._worker_draining,
|
||||
) = original_state
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
DistributedLease,
|
||||
RuntimeIdentity,
|
||||
RuntimeNode,
|
||||
RuntimeNodeState,
|
||||
StaleFence,
|
||||
acquire_lease,
|
||||
assert_lease_fence,
|
||||
heartbeat_runtime_node,
|
||||
list_runtime_nodes,
|
||||
register_runtime_node,
|
||||
request_runtime_node_drain,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _session() -> tuple[Session, object]:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[RuntimeNode.__table__, DistributedLease.__table__],
|
||||
)
|
||||
return Session(engine, expire_on_commit=False), engine
|
||||
|
||||
|
||||
def _identity(*, node_id: str, incarnation: str) -> RuntimeIdentity:
|
||||
return RuntimeIdentity(
|
||||
installation_id="installation-1",
|
||||
node_id=node_id,
|
||||
incarnation=incarnation,
|
||||
role="worker",
|
||||
software_version="0.1.14",
|
||||
composition_hash="a" * 64,
|
||||
queues=("default",),
|
||||
)
|
||||
|
||||
|
||||
def test_node_incarnation_guards_heartbeat_and_drain_state() -> None:
|
||||
session, engine = _session()
|
||||
now = datetime(2026, 8, 1, 10, 0, tzinfo=timezone.utc)
|
||||
first = _identity(node_id="worker-1", incarnation="first")
|
||||
replacement = _identity(node_id="worker-1", incarnation="replacement")
|
||||
try:
|
||||
register_runtime_node(session, first, now=now)
|
||||
heartbeat_runtime_node(session, first, now=now + timedelta(seconds=5))
|
||||
register_runtime_node(session, replacement, now=now + timedelta(seconds=10))
|
||||
|
||||
with pytest.raises(RuntimeError, match="stale node incarnation"):
|
||||
heartbeat_runtime_node(session, first, now=now + timedelta(seconds=15))
|
||||
|
||||
node = request_runtime_node_drain(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
node_id="worker-1",
|
||||
reason="rolling update",
|
||||
now=now + timedelta(seconds=20),
|
||||
)
|
||||
assert node.state == RuntimeNodeState.DRAINING.value
|
||||
rows = list_runtime_nodes(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
stale_after_seconds=60,
|
||||
now=now + timedelta(seconds=25),
|
||||
)
|
||||
assert rows[0]["incarnation"] == "replacement"
|
||||
assert rows[0]["state"] == "draining"
|
||||
assert rows[0]["stale"] is False
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_expired_lease_reassignment_increments_fence() -> None:
|
||||
session, engine = _session()
|
||||
now = datetime(2026, 8, 1, 10, 0, tzinfo=timezone.utc)
|
||||
try:
|
||||
first = acquire_lease(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
resource_key="migration:release",
|
||||
holder_node_id="node-a",
|
||||
holder_incarnation="a1",
|
||||
ttl_seconds=30,
|
||||
now=now,
|
||||
)
|
||||
assert first is not None and first.fencing_token == 1
|
||||
blocked = acquire_lease(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
resource_key="migration:release",
|
||||
holder_node_id="node-b",
|
||||
holder_incarnation="b1",
|
||||
ttl_seconds=30,
|
||||
now=now + timedelta(seconds=10),
|
||||
)
|
||||
assert blocked is None
|
||||
|
||||
replacement = acquire_lease(
|
||||
session,
|
||||
installation_id="installation-1",
|
||||
resource_key="migration:release",
|
||||
holder_node_id="node-b",
|
||||
holder_incarnation="b1",
|
||||
ttl_seconds=30,
|
||||
now=now + timedelta(seconds=31),
|
||||
)
|
||||
assert replacement is not None
|
||||
assert replacement.fencing_token == 2
|
||||
with pytest.raises(StaleFence):
|
||||
assert_lease_fence(
|
||||
session,
|
||||
first,
|
||||
now=now + timedelta(seconds=32),
|
||||
)
|
||||
assert_lease_fence(
|
||||
session,
|
||||
replacement,
|
||||
now=now + timedelta(seconds=32),
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.commands.wait_for_database import main
|
||||
|
||||
|
||||
class WaitForDatabaseTests(unittest.TestCase):
|
||||
def test_waits_until_exact_configured_heads_are_visible(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.commands.wait_for_database.configured_migration_heads",
|
||||
return_value=("head-a", "head-b"),
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.commands.wait_for_database.database_migration_heads",
|
||||
side_effect=[("head-a",), ("head-a", "head-b")],
|
||||
),
|
||||
patch("govoplan_core.commands.wait_for_database.time.sleep"),
|
||||
):
|
||||
result = main(["--timeout-seconds", "1", "--poll-seconds", "0.01"])
|
||||
|
||||
self.assertEqual(0, result)
|
||||
|
||||
def test_returns_temporary_failure_when_heads_do_not_match(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.commands.wait_for_database.configured_migration_heads",
|
||||
return_value=("head-b",),
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.commands.wait_for_database.database_migration_heads",
|
||||
return_value=("head-a",),
|
||||
),
|
||||
):
|
||||
result = main(["--timeout-seconds", "0"])
|
||||
|
||||
self.assertEqual(75, result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user