feat: implement institutional governance and recovery architecture
This commit is contained in:
@@ -1007,6 +1007,44 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
||||
report["proof_scope"]["target_environment"]["observed_subject_id"],
|
||||
)
|
||||
|
||||
def test_reference_readiness_scopes_require_release_bound_signed_proof(
|
||||
self,
|
||||
) -> None:
|
||||
installed = matching_installed_evidence(self.assessment)
|
||||
catalog, keyring = signed_catalog(
|
||||
self.assessment,
|
||||
release_artifacts=catalog_artifacts_for(installed),
|
||||
)
|
||||
readiness_scopes = (
|
||||
"target_environment",
|
||||
"accessibility",
|
||||
"privacy",
|
||||
"security",
|
||||
"operations",
|
||||
"recovery",
|
||||
)
|
||||
proof, authority = signed_boundary_evidence(
|
||||
assessment=self.assessment,
|
||||
installed=installed,
|
||||
claims=[boundary_claim(scope, "passed") for scope in readiness_scopes],
|
||||
allowed_scopes=list(readiness_scopes),
|
||||
)
|
||||
|
||||
report = self.review(
|
||||
catalog=catalog,
|
||||
keyring=keyring,
|
||||
installed_evidence=installed,
|
||||
boundary_evidence=proof,
|
||||
authority_keyring=authority,
|
||||
)
|
||||
|
||||
self.assertEqual("current", report["status"])
|
||||
for scope in readiness_scopes:
|
||||
with self.subTest(scope=scope):
|
||||
self.assertTrue(report["proof_scope"][scope]["checked"])
|
||||
self.assertTrue(report["proof_scope"][scope]["valid"])
|
||||
self.assertTrue(report["proof_scope"]["reference_readiness"]["valid"])
|
||||
|
||||
def test_external_provider_claim_requires_explicit_matching_subject(self) -> None:
|
||||
catalog, keyring = signed_catalog(self.assessment)
|
||||
installed = matching_installed_evidence(self.assessment)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationPackageManifest,
|
||||
ConfigurationPreflightContext,
|
||||
configuration_package_claim_issues,
|
||||
dry_run_configuration_package,
|
||||
)
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGE_ROOT = ROOT / "packages"
|
||||
|
||||
|
||||
class ConfigurationPackageArtifactTests(unittest.TestCase):
|
||||
def test_product_package_manifests_are_portable_and_evidence_backed(self) -> None:
|
||||
paths = tuple(sorted(PACKAGE_ROOT.glob("*/*/package.json")))
|
||||
self.assertGreaterEqual(len(paths), 2)
|
||||
package_ids: set[str] = set()
|
||||
|
||||
for path in paths:
|
||||
manifest = ConfigurationPackageManifest.from_mapping(
|
||||
json.loads(path.read_text(encoding="utf-8"))
|
||||
)
|
||||
self.assertNotIn(manifest.package_id, package_ids)
|
||||
package_ids.add(manifest.package_id)
|
||||
self.assertEqual((), configuration_package_claim_issues(manifest))
|
||||
for evidence in manifest.evidence:
|
||||
evidence_path = ROOT / evidence.reference
|
||||
self.assertTrue(
|
||||
evidence_path.is_file(),
|
||||
f"Missing evidence {evidence.reference} for {manifest.package_id}",
|
||||
)
|
||||
if evidence.checksum is not None:
|
||||
self.assertEqual(
|
||||
evidence.checksum,
|
||||
"sha256:" + hashlib.sha256(evidence_path.read_bytes()).hexdigest(),
|
||||
f"Stale evidence checksum for {manifest.package_id}: {evidence.reference}",
|
||||
)
|
||||
for requirement in (
|
||||
*manifest.required_modules,
|
||||
*manifest.optional_modules,
|
||||
):
|
||||
repository = ROOT.parent / (
|
||||
"govoplan-" + requirement.module_id.replace("_", "-")
|
||||
)
|
||||
self.assertTrue(
|
||||
repository.is_dir(),
|
||||
f"Missing repository for {requirement.module_id}",
|
||||
)
|
||||
|
||||
result = dry_run_configuration_package(
|
||||
manifest,
|
||||
(),
|
||||
ConfigurationPreflightContext(
|
||||
installed_modules={
|
||||
item.module_id: item.version or "workspace"
|
||||
for item in manifest.required_modules
|
||||
},
|
||||
capabilities=frozenset(manifest.required_capabilities),
|
||||
),
|
||||
)
|
||||
self.assertFalse(
|
||||
any(item.severity == "blocker" for item in result.diagnostics),
|
||||
tuple(item.to_dict() for item in result.diagnostics),
|
||||
)
|
||||
|
||||
self.assertIn("product.governed-communication", package_ids)
|
||||
self.assertIn("product.governed-data-assurance", package_ids)
|
||||
self.assertIn("product.service-to-decision", package_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -31,6 +31,7 @@ from govoplan_deploy.bundle import ( # noqa: E402
|
||||
)
|
||||
from govoplan_deploy.cli import _receipt_uses_direct_web_port, main # noqa: E402
|
||||
import govoplan_deploy.cli as deployment_cli # noqa: E402
|
||||
from govoplan_deploy.kubernetes import render_kubernetes # noqa: E402
|
||||
from govoplan_deploy.model import ( # noqa: E402
|
||||
SpecError,
|
||||
default_spec,
|
||||
@@ -38,6 +39,12 @@ from govoplan_deploy.model import ( # noqa: E402
|
||||
)
|
||||
from govoplan_deploy.planning import _endpoint_check, build_plan # noqa: E402
|
||||
import govoplan_deploy.planning as deployment_planning # noqa: E402
|
||||
from govoplan_deploy.recovery import ( # noqa: E402
|
||||
DeploymentOperationJournal,
|
||||
load_operation,
|
||||
recover_operation,
|
||||
write_applied_state,
|
||||
)
|
||||
|
||||
|
||||
def run_cli(arguments: list[str]) -> tuple[int, str, str]:
|
||||
@@ -49,6 +56,242 @@ def run_cli(arguments: list[str]) -> tuple[int, str, str]:
|
||||
|
||||
|
||||
class DeploymentInstallerTests(unittest.TestCase):
|
||||
def test_pre_migration_failure_restores_checksum_verified_applied_bundle(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-deploy-recovery-"
|
||||
) as directory:
|
||||
paths = bundle_paths(Path(directory))
|
||||
paths.root.chmod(0o700)
|
||||
spec = default_spec()
|
||||
previous_environment = initial_secrets(spec)
|
||||
atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600)
|
||||
write_env(paths.env, previous_environment)
|
||||
atomic_write(
|
||||
paths.compose, canonical_json(render_compose(spec)), mode=0o600
|
||||
)
|
||||
atomic_write(
|
||||
paths.receipt, canonical_json({"spec_sha256": "old"}), mode=0o600
|
||||
)
|
||||
write_applied_state(paths)
|
||||
|
||||
atomic_write(paths.spec, canonical_json({"changed": True}), mode=0o600)
|
||||
journal = DeploymentOperationJournal.begin(
|
||||
paths,
|
||||
plan={"installation_id": spec.installation_id},
|
||||
)
|
||||
journal.failed(RuntimeError("pull failed"))
|
||||
|
||||
result = recover_operation(paths, operation_id=journal.operation_id)
|
||||
|
||||
self.assertEqual("configuration_restored", result.action)
|
||||
self.assertEqual(spec.to_dict(), json.loads(paths.spec.read_text()))
|
||||
self.assertEqual(previous_environment, read_env(paths.env))
|
||||
|
||||
def test_post_migration_failure_requires_forward_recovery(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-deploy-recovery-"
|
||||
) as directory:
|
||||
paths = bundle_paths(Path(directory))
|
||||
paths.root.chmod(0o700)
|
||||
spec = default_spec()
|
||||
atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600)
|
||||
write_env(paths.env, initial_secrets(spec))
|
||||
atomic_write(
|
||||
paths.compose, canonical_json(render_compose(spec)), mode=0o600
|
||||
)
|
||||
write_applied_state(paths)
|
||||
journal = DeploymentOperationJournal.begin(
|
||||
paths,
|
||||
plan={"installation_id": spec.installation_id},
|
||||
)
|
||||
journal.migration_started()
|
||||
journal.failed(RuntimeError("health failed"))
|
||||
|
||||
result = recover_operation(paths, operation_id=journal.operation_id)
|
||||
|
||||
self.assertEqual("forward_recovery", result.action)
|
||||
self.assertIn("not restored", result.detail)
|
||||
|
||||
def test_recovery_refuses_tampered_migration_boundary(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-deploy-recovery-"
|
||||
) as directory:
|
||||
paths = bundle_paths(Path(directory))
|
||||
paths.root.chmod(0o700)
|
||||
spec = default_spec()
|
||||
journal = DeploymentOperationJournal.begin(
|
||||
paths,
|
||||
plan={"installation_id": spec.installation_id},
|
||||
)
|
||||
journal.migration_started()
|
||||
payload = json.loads(journal.path.joinpath("operation.json").read_text())
|
||||
payload["migration_started"] = False
|
||||
atomic_write(
|
||||
journal.path / "operation.json",
|
||||
canonical_json(payload),
|
||||
mode=0o600,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "migration boundary"):
|
||||
load_operation(paths, operation_id=journal.operation_id)
|
||||
|
||||
def test_recovery_verifies_complete_snapshot_before_mutating_bundle(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-deploy-recovery-"
|
||||
) as directory:
|
||||
paths = bundle_paths(Path(directory))
|
||||
paths.root.chmod(0o700)
|
||||
spec = default_spec()
|
||||
atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600)
|
||||
write_env(paths.env, initial_secrets(spec))
|
||||
atomic_write(
|
||||
paths.compose, canonical_json(render_compose(spec)), mode=0o600
|
||||
)
|
||||
write_applied_state(paths)
|
||||
journal = DeploymentOperationJournal.begin(
|
||||
paths,
|
||||
plan={"installation_id": spec.installation_id},
|
||||
)
|
||||
changed_spec = canonical_json({"changed": True})
|
||||
atomic_write(paths.spec, changed_spec, mode=0o600)
|
||||
atomic_write(
|
||||
journal.path / "before" / "compose.json", b"tampered", mode=0o600
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "checksum failed"):
|
||||
recover_operation(paths, operation_id=journal.operation_id)
|
||||
|
||||
self.assertEqual(changed_spec, paths.spec.read_bytes())
|
||||
|
||||
def test_recovery_refuses_tampered_desired_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-deploy-recovery-"
|
||||
) as directory:
|
||||
paths = bundle_paths(Path(directory))
|
||||
paths.root.chmod(0o700)
|
||||
spec = default_spec()
|
||||
journal = DeploymentOperationJournal.begin(
|
||||
paths,
|
||||
plan={"installation_id": spec.installation_id},
|
||||
)
|
||||
payload = json.loads(journal.path.joinpath("operation.json").read_text())
|
||||
payload["desired"]["installation_id"] = "other-installation"
|
||||
atomic_write(
|
||||
journal.path / "operation.json",
|
||||
canonical_json(payload),
|
||||
mode=0o600,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "plan"):
|
||||
load_operation(paths, operation_id=journal.operation_id)
|
||||
|
||||
def test_kubernetes_export_is_stateless_and_uses_exact_secret_contract(
|
||||
self,
|
||||
) -> None:
|
||||
spec = default_spec(
|
||||
installation_id="govoplan-cluster",
|
||||
postgres_mode="external",
|
||||
redis_mode="external",
|
||||
storage_mode="s3",
|
||||
api_replicas=3,
|
||||
web_replicas=2,
|
||||
worker_replicas=4,
|
||||
api_image="registry.example.test/govoplan-api@sha256:" + "a" * 64,
|
||||
web_image="registry.example.test/govoplan-web@sha256:" + "b" * 64,
|
||||
)
|
||||
environment = initial_secrets(
|
||||
spec,
|
||||
supplied={
|
||||
"DATABASE_URL": "postgresql+psycopg://user:db-secret@postgres.example.test/govoplan",
|
||||
"GOVOPLAN_DATABASE_URL_PGTOOLS": "postgresql://user:db-secret@postgres.example.test/govoplan",
|
||||
"REDIS_URL": "rediss://:redis-secret@redis.example.test/0",
|
||||
"FILE_STORAGE_S3_ENDPOINT_URL": "https://s3.example.test",
|
||||
"FILE_STORAGE_S3_REGION": "eu-test-1",
|
||||
"FILE_STORAGE_S3_ACCESS_KEY_ID": "object-key",
|
||||
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "object-secret",
|
||||
"FILE_STORAGE_S3_BUCKET": "govoplan",
|
||||
},
|
||||
)
|
||||
|
||||
manifest = render_kubernetes(spec, environment)
|
||||
rendered = json.dumps(manifest, sort_keys=True)
|
||||
kinds = [item["kind"] for item in manifest["items"]]
|
||||
deployments = {
|
||||
item["metadata"]["name"]: item
|
||||
for item in manifest["items"]
|
||||
if item["kind"] == "Deployment"
|
||||
}
|
||||
|
||||
self.assertNotIn("db-secret", rendered)
|
||||
self.assertNotIn("redis-secret", rendered)
|
||||
self.assertNotIn("object-secret", rendered)
|
||||
self.assertNotIn("PersistentVolumeClaim", kinds)
|
||||
self.assertNotIn("StatefulSet", kinds)
|
||||
self.assertEqual(3, deployments["govoplan-cluster-api"]["spec"]["replicas"])
|
||||
self.assertEqual(4, deployments["govoplan-cluster-worker"]["spec"]["replicas"])
|
||||
scheduler_command = deployments["govoplan-cluster-scheduler"]["spec"][
|
||||
"template"
|
||||
]["spec"]["containers"][0]["command"]
|
||||
self.assertIn("govoplan_core.commands.fenced_run", scheduler_command)
|
||||
api_init_command = deployments["govoplan-cluster-api"]["spec"]["template"][
|
||||
"spec"
|
||||
]["initContainers"][0]["command"]
|
||||
self.assertIn("govoplan_core.commands.wait_for_database", api_init_command)
|
||||
migration = next(item for item in manifest["items"] if item["kind"] == "Job")
|
||||
self.assertRegex(
|
||||
migration["metadata"]["name"],
|
||||
r"^govoplan-cluster-migrate-[0-9a-f]{8}$",
|
||||
)
|
||||
self.assertEqual(
|
||||
"forward-recovery",
|
||||
migration["metadata"]["annotations"]["govoplan.add-ideas.de/recovery-mode"],
|
||||
)
|
||||
config = next(item for item in manifest["items"] if item["kind"] == "ConfigMap")
|
||||
self.assertEqual("shared", config["data"]["GOVOPLAN_STATE_PROFILE"])
|
||||
self.assertEqual("3", config["data"]["GOVOPLAN_EXPECTED_API_REPLICAS"])
|
||||
self.assertEqual(
|
||||
[{"name": "Host", "value": "127.0.0.1"}],
|
||||
deployments["govoplan-cluster-api"]["spec"]["template"]["spec"][
|
||||
"containers"
|
||||
][0]["readinessProbe"]["httpGet"]["httpHeaders"],
|
||||
)
|
||||
|
||||
def test_kubernetes_export_rejects_mutable_release_images(self) -> None:
|
||||
spec = default_spec(
|
||||
installation_id="govoplan-cluster",
|
||||
postgres_mode="external",
|
||||
redis_mode="external",
|
||||
storage_mode="s3",
|
||||
api_image="registry.example.test/govoplan-api:latest",
|
||||
web_image="registry.example.test/govoplan-web:latest",
|
||||
)
|
||||
environment = initial_secrets(
|
||||
spec,
|
||||
supplied={
|
||||
"DATABASE_URL": "postgresql+psycopg://user:secret@postgres.example.test/govoplan",
|
||||
"GOVOPLAN_DATABASE_URL_PGTOOLS": "postgresql://user:secret@postgres.example.test/govoplan",
|
||||
"REDIS_URL": "rediss://:secret@redis.example.test/0",
|
||||
"FILE_STORAGE_S3_ENDPOINT_URL": "https://s3.example.test",
|
||||
"FILE_STORAGE_S3_REGION": "eu-test-1",
|
||||
"FILE_STORAGE_S3_ACCESS_KEY_ID": "object-key",
|
||||
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "object-secret",
|
||||
"FILE_STORAGE_S3_BUCKET": "govoplan",
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "digest-pinned"):
|
||||
render_kubernetes(spec, environment)
|
||||
|
||||
def test_kubernetes_export_rejects_installer_managed_state_services(self) -> None:
|
||||
spec = default_spec(
|
||||
installation_id="govoplan-cluster",
|
||||
storage_mode="garage",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "external shared PostgreSQL"):
|
||||
render_kubernetes(spec, initial_secrets(spec))
|
||||
|
||||
def test_default_bundle_has_base_modules_and_managed_dependencies(self) -> None:
|
||||
spec = default_spec()
|
||||
compose = render_compose(spec)
|
||||
@@ -80,6 +323,7 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
self.assertNotIn("scheduler", compose["services"])
|
||||
self.assertEqual("false", values["CELERY_ENABLED"])
|
||||
self.assertEqual("true", values["GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE"])
|
||||
self.assertEqual("local", values["GOVOPLAN_STATE_PROFILE"])
|
||||
|
||||
def test_self_hosted_rejects_insecure_or_test_only_choices(self) -> None:
|
||||
with self.assertRaisesRegex(SpecError, "must use HTTPS"):
|
||||
@@ -154,7 +398,7 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual("rediss://:secret@redis.example.test/0", values["REDIS_URL"])
|
||||
|
||||
def test_s3_requires_complete_private_configuration_and_https_in_production(
|
||||
def test_s3_requires_complete_private_configuration_and_https_endpoint(
|
||||
self,
|
||||
) -> None:
|
||||
evaluation = default_spec(storage_mode="s3")
|
||||
@@ -168,17 +412,21 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "secret",
|
||||
"FILE_STORAGE_S3_BUCKET": "govoplan",
|
||||
}
|
||||
self.assertEqual(
|
||||
"s3",
|
||||
initial_secrets(evaluation, supplied=supplied)["FILE_STORAGE_BACKEND"],
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "must use HTTPS"):
|
||||
initial_secrets(evaluation, supplied=supplied)
|
||||
supplied["FILE_STORAGE_S3_ENDPOINT_URL"] = "https://s3.example.test"
|
||||
values = initial_secrets(evaluation, supplied=supplied)
|
||||
self.assertEqual("s3", values["FILE_STORAGE_BACKEND"])
|
||||
self.assertEqual("true", values["FILE_STORAGE_S3_ENDPOINT_TRUSTED"])
|
||||
production = default_spec(
|
||||
profile="self-hosted",
|
||||
public_url="https://govoplan.example.test",
|
||||
storage_mode="s3",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "must use HTTPS"):
|
||||
initial_secrets(production, supplied=supplied)
|
||||
self.assertEqual(
|
||||
"s3",
|
||||
initial_secrets(production, supplied=supplied)["FILE_STORAGE_BACKEND"],
|
||||
)
|
||||
|
||||
def test_managed_garage_bootstraps_private_s3_storage(self) -> None:
|
||||
spec = default_spec(storage_mode="garage")
|
||||
@@ -187,6 +435,7 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual("s3", values["FILE_STORAGE_BACKEND"])
|
||||
self.assertEqual("true", values["FILE_STORAGE_S3_DEPLOYMENT_MANAGED"])
|
||||
self.assertEqual("false", values["FILE_STORAGE_S3_ENDPOINT_TRUSTED"])
|
||||
self.assertEqual(
|
||||
"http://garage:3900",
|
||||
values["FILE_STORAGE_S3_ENDPOINT_URL"],
|
||||
@@ -244,6 +493,16 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("server-template web- 2 web:8080", config)
|
||||
self.assertIn("server-template api- 3 api:8000", config)
|
||||
self.assertIn("option httpchk GET /health/ready", config)
|
||||
self.assertIn("http-check send hdr Host 127.0.0.1", config)
|
||||
self.assertIn(
|
||||
"headers={'Host': '127.0.0.1'}",
|
||||
compose["services"]["api"]["healthcheck"]["test"][-1],
|
||||
)
|
||||
self.assertEqual(
|
||||
"host-shared",
|
||||
initial_secrets(spec)["GOVOPLAN_STATE_PROFILE"],
|
||||
)
|
||||
|
||||
def test_multi_replica_runtime_requires_shared_redis(self) -> None:
|
||||
with self.assertRaisesRegex(SpecError, "multiple API replicas"):
|
||||
@@ -725,11 +984,17 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
for index, command in enumerate(commands)
|
||||
if "--remove-orphans" in command
|
||||
)
|
||||
stop_index = next(
|
||||
index
|
||||
for index, command in enumerate(commands)
|
||||
if "stop" in command and "--timeout" in command
|
||||
)
|
||||
self.assertLess(stop_index, migrate_index)
|
||||
self.assertLess(migrate_index, runtime_index)
|
||||
self.assertIn("postgres", commands[0])
|
||||
self.assertIn("redis", commands[0])
|
||||
wait_for_health.assert_called_once_with(
|
||||
"http://127.0.0.1:8080/health",
|
||||
"http://127.0.0.1:8080/health/ready",
|
||||
timeout_seconds=120.0,
|
||||
)
|
||||
receipt = json.loads((root / "receipt.json").read_text(encoding="utf-8"))
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
ActorRepresentationReference,
|
||||
DecisionEffectReference,
|
||||
EvidenceReference,
|
||||
InformationGovernanceReference,
|
||||
InstitutionalReference,
|
||||
LegalBasisReference,
|
||||
MandateDefinition,
|
||||
PartyRepresentation,
|
||||
PartySubjectReference,
|
||||
ProcedureParty,
|
||||
ServiceBinding,
|
||||
ServiceDefinition,
|
||||
TemporalRevision,
|
||||
service_launch_capability,
|
||||
)
|
||||
from govoplan_cases.backend.party_context import CasePartyContext
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
CaseRecordRevision,
|
||||
CaseStatusDefinition,
|
||||
CaseTimelineEntry,
|
||||
CaseTypeDefinition,
|
||||
)
|
||||
from govoplan_cases.backend.service import (
|
||||
create_case_from_intake,
|
||||
get_case,
|
||||
upsert_case_status,
|
||||
upsert_case_type,
|
||||
)
|
||||
from govoplan_cases.backend.service_intake import CaseServiceIntake
|
||||
from govoplan_committee.backend.db.models import (
|
||||
CommitteeDecisionProjection,
|
||||
CommitteeWorkspaceEvent,
|
||||
CommitteeWorkspaceRevision,
|
||||
)
|
||||
from govoplan_committee.backend.decision_path import (
|
||||
CommitteeDecisionPath,
|
||||
CommitteeDecisionProposal,
|
||||
)
|
||||
from govoplan_committee.backend.workspace import (
|
||||
CommitteeWorkspaceRecord,
|
||||
SqlCommitteeWorkspace,
|
||||
get_workspace_object,
|
||||
record_workspace_object,
|
||||
)
|
||||
from govoplan_portal.backend.service_directory import PortalServiceDirectory
|
||||
from govoplan_decisions.backend.db.models import FormalDecisionRevision
|
||||
from govoplan_decisions.backend.service import SqlDecisionRegistry
|
||||
from govoplan_mandates.backend.db.models import MandateRevision
|
||||
from govoplan_mandates.backend.service import SqlMandateResolver, record_mandate
|
||||
from govoplan_parties.backend.db.models import ProcedurePartyRevision
|
||||
from govoplan_parties.backend.service import SqlPartyResolver, record_procedure_party
|
||||
from govoplan_services.backend.db.models import ServiceDefinitionRevision
|
||||
from govoplan_services.backend.service import SqlServiceDefinitionProvider, record_service_definition
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _reference(
|
||||
kind: str,
|
||||
object_id: str,
|
||||
*,
|
||||
owner: str,
|
||||
version: str = "1",
|
||||
) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
version=version,
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _evidence(kind: str, evidence_id: str, owner: str) -> EvidenceReference:
|
||||
return EvidenceReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
evidence_id=evidence_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
checksum=f"sha256:{evidence_id}",
|
||||
captured_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "account-1"
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capabilities: dict[str, object]) -> None:
|
||||
self.capabilities = capabilities
|
||||
|
||||
def has(self, module_id: str) -> bool:
|
||||
return module_id in {
|
||||
"portal",
|
||||
"cases",
|
||||
"committee",
|
||||
"postbox",
|
||||
"services",
|
||||
"parties",
|
||||
"mandates",
|
||||
"decisions",
|
||||
}
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.capabilities
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
return self.capabilities[name]
|
||||
|
||||
|
||||
class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
||||
def test_service_to_formal_outcome_retains_governed_context(self) -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
ServiceDefinitionRevision.__table__,
|
||||
CaseStatusDefinition.__table__,
|
||||
CaseTypeDefinition.__table__,
|
||||
CaseIdentity.__table__,
|
||||
CaseRecordRevision.__table__,
|
||||
CaseAccessGrant.__table__,
|
||||
CaseTimelineEntry.__table__,
|
||||
ProcedurePartyRevision.__table__,
|
||||
MandateRevision.__table__,
|
||||
FormalDecisionRevision.__table__,
|
||||
CommitteeWorkspaceRevision.__table__,
|
||||
CommitteeWorkspaceEvent.__table__,
|
||||
CommitteeDecisionProjection.__table__,
|
||||
):
|
||||
table.create(engine)
|
||||
session = Session(engine)
|
||||
principal = _Principal()
|
||||
self.addCleanup(engine.dispose)
|
||||
self.addCleanup(session.close)
|
||||
|
||||
organization = _reference(
|
||||
"organization_unit",
|
||||
"permit-office",
|
||||
owner="organizations",
|
||||
)
|
||||
function = _reference("function", "permit-officer", owner="organizations")
|
||||
jurisdiction = _reference(
|
||||
"jurisdiction",
|
||||
"city-1",
|
||||
owner="organizations",
|
||||
)
|
||||
mandate_ref = _reference(
|
||||
"mandate",
|
||||
"permit-mandate",
|
||||
owner="mandates",
|
||||
version="4",
|
||||
)
|
||||
legal_basis = LegalBasisReference(
|
||||
kind="law",
|
||||
authority="Example legislature",
|
||||
reference="permit-law:3",
|
||||
version="2026-01",
|
||||
effective_from=NOW - timedelta(days=100),
|
||||
)
|
||||
service = ServiceDefinition(
|
||||
reference=_reference(
|
||||
"service",
|
||||
"permit-service",
|
||||
owner="services",
|
||||
version="5",
|
||||
),
|
||||
key="permit.apply",
|
||||
temporal=TemporalRevision(
|
||||
revision="5",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=30),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
change_reason="Service published.",
|
||||
),
|
||||
title="Apply for a permit",
|
||||
audience=("resident",),
|
||||
legal_bases=(legal_basis,),
|
||||
required_evidence_types=("application", "identity"),
|
||||
channels=("portal", "postbox"),
|
||||
responsible_organization_ref=organization,
|
||||
responsible_function_ref=function,
|
||||
mandate_ref=mandate_ref,
|
||||
jurisdiction_refs=(jurisdiction,),
|
||||
bindings=(
|
||||
ServiceBinding("case", "permit-application"),
|
||||
ServiceBinding("workflow", "workflow:permit-review"),
|
||||
ServiceBinding("result", "decision:permit"),
|
||||
),
|
||||
remedy_refs=("review:administrative-court",),
|
||||
publication_state="published",
|
||||
)
|
||||
record_service_definition(session, principal, definition=service)
|
||||
service_registry = _Registry(
|
||||
{
|
||||
"services.definitions": SqlServiceDefinitionProvider(),
|
||||
service_launch_capability("case"): object(),
|
||||
}
|
||||
)
|
||||
entry = PortalServiceDirectory(service_registry).list_entries(
|
||||
session,
|
||||
principal,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("resident",),
|
||||
)[0]
|
||||
self.assertTrue(entry.available)
|
||||
intake = CaseServiceIntake().plan(
|
||||
entry.definition,
|
||||
case_id="case-1",
|
||||
effective_at=NOW,
|
||||
)
|
||||
application_evidence = _evidence("document", "application-1", "files")
|
||||
upsert_case_status(
|
||||
session,
|
||||
principal,
|
||||
status_key="intake",
|
||||
label="Intake",
|
||||
)
|
||||
upsert_case_status(
|
||||
session,
|
||||
principal,
|
||||
status_key="decided",
|
||||
label="Decided",
|
||||
category="decided",
|
||||
)
|
||||
upsert_case_type(
|
||||
session,
|
||||
principal,
|
||||
type_key="permit-application",
|
||||
label="Permit application",
|
||||
initial_status_key="intake",
|
||||
allowed_status_keys=("intake", "decided"),
|
||||
)
|
||||
case_record = create_case_from_intake(
|
||||
session,
|
||||
principal,
|
||||
plan=intake,
|
||||
case_number="PERMIT-2026-0001",
|
||||
title="Permit application",
|
||||
status_key=None,
|
||||
opened_at=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Portal application received.",
|
||||
idempotency_key="journey-case-create",
|
||||
evidence_refs=(application_evidence,),
|
||||
deadline_at=NOW + timedelta(days=30),
|
||||
)
|
||||
|
||||
applicant = _reference("party", "applicant", owner="parties")
|
||||
representative = _reference("party", "representative", owner="parties")
|
||||
address_evidence = _evidence(
|
||||
"snapshot",
|
||||
"address-snapshot-1",
|
||||
"addresses",
|
||||
)
|
||||
representative_party = ProcedureParty(
|
||||
reference=representative,
|
||||
procedure_ref=case_record.reference,
|
||||
role="representative",
|
||||
subject=PartySubjectReference(
|
||||
kind="identity",
|
||||
provider="identity",
|
||||
subject_id="identity-2",
|
||||
tenant_id="tenant-1",
|
||||
version="2",
|
||||
),
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=1),
|
||||
change_reason="Representative added to the procedure.",
|
||||
),
|
||||
permitted_channels=("postbox",),
|
||||
preferred_channels=("postbox",),
|
||||
delivery_recipient=True,
|
||||
representations=(
|
||||
PartyRepresentation(
|
||||
representative_party_ref=representative,
|
||||
represented_party_ref=applicant,
|
||||
power_ref="power-1",
|
||||
permitted_actions=("submit", "receive"),
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=1),
|
||||
change_reason="Representation power recorded.",
|
||||
),
|
||||
evidence=(address_evidence,),
|
||||
),
|
||||
),
|
||||
contact_snapshot_refs=("addresses:snapshot-1",),
|
||||
evidence=(address_evidence,),
|
||||
)
|
||||
record_procedure_party(session, principal, party=representative_party)
|
||||
party_context = CasePartyContext(
|
||||
_Registry({"parties.resolver": SqlPartyResolver()})
|
||||
)
|
||||
parties = party_context.resolve(
|
||||
session,
|
||||
principal,
|
||||
case_ref=case_record.reference,
|
||||
effective_at=NOW,
|
||||
)
|
||||
delivery_target = party_context.delivery_targets(
|
||||
parties,
|
||||
channel="postbox",
|
||||
)[0]
|
||||
|
||||
mandate = MandateDefinition(
|
||||
reference=mandate_ref,
|
||||
temporal=TemporalRevision(
|
||||
revision="4",
|
||||
valid_from=NOW - timedelta(days=30),
|
||||
valid_to=NOW + timedelta(days=30),
|
||||
recorded_at=NOW - timedelta(days=31),
|
||||
change_reason="Permit authority delegated.",
|
||||
),
|
||||
task_types=("committee.formal_decision",),
|
||||
authority_types=("permit",),
|
||||
organization_unit_refs=(organization,),
|
||||
function_refs=(function,),
|
||||
jurisdiction_refs=(jurisdiction,),
|
||||
legal_bases=(legal_basis,),
|
||||
evidence=(_evidence("record", "mandate-record-4", "mandates"),),
|
||||
authority_ceiling="permit:standard",
|
||||
)
|
||||
record_mandate(session, principal, definition=mandate)
|
||||
workspace = SqlCommitteeWorkspace()
|
||||
body = CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind="body",
|
||||
object_id="permit-board",
|
||||
revision=1,
|
||||
state="active",
|
||||
title="Permit board",
|
||||
recorded_at=NOW,
|
||||
change_reason="Permit board configured.",
|
||||
attributes={
|
||||
"organization_unit_ref": organization.to_dict(),
|
||||
"function_refs": [function.to_dict()],
|
||||
"quorum": {"minimum_count": 1},
|
||||
},
|
||||
)
|
||||
meeting = CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind="meeting",
|
||||
object_id="meeting-1",
|
||||
revision=1,
|
||||
state="open",
|
||||
title="Permit board meeting",
|
||||
parent_id=body.object_id,
|
||||
recorded_at=NOW,
|
||||
change_reason="Meeting opened.",
|
||||
attributes={
|
||||
"starts_at": NOW.isoformat(),
|
||||
"ends_at": (NOW + timedelta(hours=1)).isoformat(),
|
||||
},
|
||||
context=case_record.context,
|
||||
)
|
||||
agenda = CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind="agenda_item",
|
||||
object_id="item-1",
|
||||
revision=1,
|
||||
state="deliberating",
|
||||
title="Permit application",
|
||||
parent_id=meeting.object_id,
|
||||
recorded_at=NOW,
|
||||
change_reason="Agenda item entered deliberation.",
|
||||
attributes={
|
||||
"position": 1,
|
||||
"subject_refs": [case_record.reference.to_dict()],
|
||||
},
|
||||
context=case_record.context,
|
||||
evidence=(application_evidence,),
|
||||
)
|
||||
approval_ref = _reference("approval", "approval-1", owner="approvals")
|
||||
vote = CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind="vote",
|
||||
object_id="vote-1",
|
||||
revision=1,
|
||||
state="closed",
|
||||
title="Vote on permit application",
|
||||
parent_id=agenda.object_id,
|
||||
recorded_at=NOW,
|
||||
change_reason="Vote result accepted.",
|
||||
attributes={
|
||||
"method": "recorded",
|
||||
"choices": ["yes", "no"],
|
||||
"eligible_count": 3,
|
||||
"cast_count": 3,
|
||||
"counts": {"yes": 3, "no": 0},
|
||||
"quorum_met": True,
|
||||
"approval_ref": approval_ref.to_dict(),
|
||||
},
|
||||
context=case_record.context,
|
||||
evidence=(_evidence("record", "vote-result-1", "committee"),),
|
||||
)
|
||||
for record, key in (
|
||||
(body, "journey-body"),
|
||||
(meeting, "journey-meeting"),
|
||||
(agenda, "journey-agenda"),
|
||||
(vote, "journey-vote"),
|
||||
):
|
||||
record_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
record=record,
|
||||
idempotency_key=key,
|
||||
)
|
||||
decision_registry = SqlDecisionRegistry()
|
||||
decision_result = CommitteeDecisionPath(
|
||||
_Registry(
|
||||
{
|
||||
"mandates.resolver": SqlMandateResolver(),
|
||||
"decisions.registry": decision_registry,
|
||||
"committee.workspace": workspace,
|
||||
}
|
||||
)
|
||||
).decide(
|
||||
session,
|
||||
principal,
|
||||
proposal=CommitteeDecisionProposal(
|
||||
tenant_id="tenant-1",
|
||||
decision_id="decision-1",
|
||||
revision="1",
|
||||
effective_at=NOW,
|
||||
meeting_ref="meeting-1",
|
||||
agenda_item_ref="item-1",
|
||||
decision_type="permit",
|
||||
subject_refs=(case_record.reference,),
|
||||
organization_unit_ref=organization,
|
||||
function_ref=function,
|
||||
actor=ActorRepresentationReference(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
represented_function_ref=function,
|
||||
mandate_ref=mandate_ref,
|
||||
),
|
||||
approval_refs=(
|
||||
approval_ref,
|
||||
),
|
||||
fact_evidence=(application_evidence, address_evidence),
|
||||
legal_bases=(legal_basis,),
|
||||
operative_result="Permit granted.",
|
||||
reasoning="The application satisfies the effective rule.",
|
||||
case_ref=case_record.reference,
|
||||
jurisdiction_refs=(jurisdiction,),
|
||||
party_refs=(applicant, representative),
|
||||
record_refs=(
|
||||
_reference("record", "record-1", owner="audit"),
|
||||
),
|
||||
remedy_refs=service.remedy_refs,
|
||||
review_refs=("review:administrative-court",),
|
||||
information_governance=InformationGovernanceReference(
|
||||
classification="restricted",
|
||||
purposes=("permit-decision", "party-delivery"),
|
||||
legal_basis_refs=("permit-law:3@2026-01",),
|
||||
retention_policy_ref="records:permit",
|
||||
disclosure_state="partly_disclosable",
|
||||
),
|
||||
),
|
||||
observed_effects=(
|
||||
DecisionEffectReference(
|
||||
effect_key="postbox.deliver_decision",
|
||||
state="confirmed",
|
||||
resource_refs=(
|
||||
f"postbox:{delivery_target.party_ref.object_id}",
|
||||
),
|
||||
audit_event_refs=("audit:delivery-1",),
|
||||
evidence_refs=(address_evidence.evidence_id,),
|
||||
),
|
||||
),
|
||||
)
|
||||
decided_agenda = replace(
|
||||
agenda,
|
||||
revision=2,
|
||||
state="decided",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Formal Decision recorded.",
|
||||
attributes={
|
||||
**dict(agenda.attributes),
|
||||
"decision_ref": decision_result.decision.reference.to_dict(),
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
record=decided_agenda,
|
||||
expected_revision=1,
|
||||
idempotency_key="journey-agenda-decided",
|
||||
)
|
||||
closed_meeting = replace(
|
||||
meeting,
|
||||
revision=2,
|
||||
state="closed",
|
||||
recorded_at=NOW + timedelta(hours=1),
|
||||
change_reason="All agenda items completed.",
|
||||
)
|
||||
record_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
record=closed_meeting,
|
||||
expected_revision=1,
|
||||
idempotency_key="journey-meeting-closed",
|
||||
)
|
||||
minute = CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind="minute",
|
||||
object_id="minute-1",
|
||||
revision=1,
|
||||
state="accepted",
|
||||
title="Accepted permit board minutes",
|
||||
parent_id=meeting.object_id,
|
||||
recorded_at=NOW + timedelta(hours=2),
|
||||
change_reason="Minutes approved.",
|
||||
attributes={
|
||||
"content_ref": _reference(
|
||||
"record",
|
||||
"meeting-minutes-1",
|
||||
owner="records",
|
||||
).to_dict(),
|
||||
"approval_ref": _reference(
|
||||
"approval",
|
||||
"minutes-approval-1",
|
||||
owner="approvals",
|
||||
).to_dict(),
|
||||
},
|
||||
context=case_record.context,
|
||||
evidence=(_evidence("record", "minutes-proof-1", "records"),),
|
||||
)
|
||||
record_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
record=minute,
|
||||
idempotency_key="journey-minute",
|
||||
)
|
||||
reconstruction = decision_result.reconstruction_payload()["decision"]
|
||||
persisted = decision_registry.get_decision(
|
||||
session,
|
||||
principal,
|
||||
reference=decision_result.decision.reference,
|
||||
)
|
||||
|
||||
self.assertTrue(decision_result.persisted_by_decision_registry)
|
||||
self.assertEqual(decision_result.decision, persisted)
|
||||
self.assertEqual(
|
||||
case_record,
|
||||
get_case(session, principal, case_id="case-1"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"decided",
|
||||
get_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind="agenda_item",
|
||||
object_id="item-1",
|
||||
).state,
|
||||
)
|
||||
self.assertEqual(service.reference, intake.context.service_ref)
|
||||
self.assertEqual("applicant", delivery_target.represented_party_refs[0].object_id)
|
||||
self.assertEqual("permit-mandate", reconstruction["authority_context"]["mandate_ref"]["object_id"])
|
||||
self.assertEqual("city-1", reconstruction["authority_context"]["jurisdiction_refs"][0]["object_id"])
|
||||
self.assertEqual("confirmed", reconstruction["observed_effects"][0]["state"])
|
||||
self.assertEqual("audit:delivery-1", reconstruction["observed_effects"][0]["audit_event_refs"][0])
|
||||
self.assertEqual("application-1", reconstruction["fact_evidence"][0]["evidence_id"])
|
||||
self.assertEqual("The application satisfies the effective rule.", reconstruction["reasoning"])
|
||||
self.assertEqual("review:administrative-court", reconstruction["review_refs"][0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_FORM_DEFINITIONS,
|
||||
CAPABILITY_SERVICE_DEFINITIONS,
|
||||
FormDefinition,
|
||||
FormFieldDefinition,
|
||||
InstitutionalReference,
|
||||
ServiceBinding,
|
||||
ServiceDefinition,
|
||||
TemporalRevision,
|
||||
service_launch_capability,
|
||||
)
|
||||
from govoplan_cases.backend.service_intake import (
|
||||
CAPABILITY_CASES_SERVICE_INTAKE,
|
||||
CaseServiceIntake,
|
||||
)
|
||||
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||
from govoplan_forms.backend.service import (
|
||||
SqlFormDefinitionProvider,
|
||||
record_form_definition,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.db.models import (
|
||||
FormInstanceEvent,
|
||||
FormInstanceIdentity,
|
||||
FormInstanceRevision,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.service import (
|
||||
FormRuntimeService,
|
||||
FormsServiceLauncher,
|
||||
)
|
||||
from govoplan_portal.backend.service_directory import PortalServiceDirectory
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _service() -> ServiceDefinition:
|
||||
return ServiceDefinition(
|
||||
reference=InstitutionalReference(
|
||||
kind="service",
|
||||
owner_module="portal",
|
||||
object_id="permit",
|
||||
tenant_id="tenant-1",
|
||||
version="5",
|
||||
),
|
||||
key="permit.apply",
|
||||
temporal=TemporalRevision(
|
||||
revision="5",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
title="Apply for a permit",
|
||||
audience=("resident",),
|
||||
required_evidence_types=("application",),
|
||||
bindings=(
|
||||
ServiceBinding("capability", CAPABILITY_CASES_SERVICE_INTAKE),
|
||||
ServiceBinding("case", "permit-application"),
|
||||
ServiceBinding("workflow", "workflow:permit-review"),
|
||||
),
|
||||
publication_state="published",
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
def __init__(self, definition: ServiceDefinition) -> None:
|
||||
self.definition = definition
|
||||
|
||||
def get_service_definition(self, session, principal, *, reference, effective_at=None):
|
||||
return self.definition
|
||||
|
||||
def list_service_definitions(self, session, principal, *, tenant_id, query="", limit=100):
|
||||
return (self.definition,)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, definition: ServiceDefinition) -> None:
|
||||
self.capabilities = {
|
||||
CAPABILITY_SERVICE_DEFINITIONS: _Provider(definition),
|
||||
CAPABILITY_CASES_SERVICE_INTAKE: CaseServiceIntake(),
|
||||
service_launch_capability("case"): object(),
|
||||
}
|
||||
|
||||
def has(self, module_id: str) -> bool:
|
||||
return module_id in {"portal", "cases"}
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.capabilities
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
return self.capabilities[name]
|
||||
|
||||
def require_capability(self, name: str) -> object:
|
||||
return self.capabilities[name]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "account-1"
|
||||
|
||||
|
||||
class _FormRegistry(_Registry):
|
||||
def __init__(self, definition: ServiceDefinition) -> None:
|
||||
super().__init__(definition)
|
||||
self.capabilities[CAPABILITY_FORM_DEFINITIONS] = (
|
||||
SqlFormDefinitionProvider()
|
||||
)
|
||||
self.capabilities[service_launch_capability("form")] = (
|
||||
FormsServiceLauncher(self)
|
||||
)
|
||||
|
||||
def has(self, module_id: str) -> bool:
|
||||
return module_id in {"portal", "forms", "forms_runtime"}
|
||||
|
||||
|
||||
class InstitutionalServiceJourneyTests(unittest.TestCase):
|
||||
def test_one_service_version_drives_portal_and_case_intake(self) -> None:
|
||||
definition = _service()
|
||||
registry = _Registry(definition)
|
||||
|
||||
entries = PortalServiceDirectory(registry).list_entries(
|
||||
None,
|
||||
None,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("resident",),
|
||||
)
|
||||
plan = registry.capability(CAPABILITY_CASES_SERVICE_INTAKE).plan(
|
||||
entries[0].definition,
|
||||
case_id="case-1",
|
||||
effective_at=NOW,
|
||||
)
|
||||
|
||||
self.assertTrue(entries[0].available)
|
||||
self.assertIs(definition, entries[0].definition)
|
||||
self.assertEqual(definition.reference, plan.service_ref)
|
||||
self.assertEqual("5", plan.context.service_ref.version)
|
||||
self.assertEqual("workflow:permit-review", plan.workflow_refs[0])
|
||||
|
||||
def test_portal_launches_exact_form_revision_and_persists_submission(self) -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
FormDefinitionRevision.__table__,
|
||||
FormInstanceIdentity.__table__,
|
||||
FormInstanceRevision.__table__,
|
||||
FormInstanceEvent.__table__,
|
||||
):
|
||||
table.create(engine)
|
||||
session = Session(engine)
|
||||
principal = _Principal()
|
||||
try:
|
||||
form = record_form_definition(
|
||||
session,
|
||||
principal,
|
||||
definition=FormDefinition(
|
||||
reference=InstitutionalReference(
|
||||
kind="form",
|
||||
owner_module="forms",
|
||||
object_id="permit-application",
|
||||
tenant_id="tenant-1",
|
||||
version="3",
|
||||
),
|
||||
key="permit-application",
|
||||
temporal=TemporalRevision(
|
||||
revision="3",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
change_reason="Publish the permit application.",
|
||||
),
|
||||
title="Permit application",
|
||||
fields=(
|
||||
FormFieldDefinition(
|
||||
key="applicant_name",
|
||||
label="Applicant name",
|
||||
required=True,
|
||||
constraints={"min_length": 2},
|
||||
),
|
||||
),
|
||||
publication_state="published",
|
||||
allow_drafts=True,
|
||||
handoff_kinds=("case",),
|
||||
),
|
||||
)
|
||||
binding = ServiceBinding(
|
||||
"form",
|
||||
f"{form.reference.object_id}/{form.reference.version}",
|
||||
)
|
||||
service = ServiceDefinition(
|
||||
reference=InstitutionalReference(
|
||||
kind="service",
|
||||
owner_module="services",
|
||||
object_id="permit",
|
||||
tenant_id="tenant-1",
|
||||
version="6",
|
||||
),
|
||||
key="permit.apply",
|
||||
temporal=TemporalRevision(
|
||||
revision="6",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
title="Apply for a permit",
|
||||
audience=("public",),
|
||||
bindings=(binding,),
|
||||
publication_state="published",
|
||||
)
|
||||
registry = _FormRegistry(service)
|
||||
directory = PortalServiceDirectory(registry)
|
||||
|
||||
launched = directory.launch_service(
|
||||
session,
|
||||
principal,
|
||||
reference=service.reference,
|
||||
requested_at=NOW,
|
||||
idempotency_key="portal-form-launch-1",
|
||||
parameters={"applicant_name": "Ada Lovelace"},
|
||||
)
|
||||
replay = directory.launch_service(
|
||||
session,
|
||||
principal,
|
||||
reference=service.reference,
|
||||
requested_at=NOW,
|
||||
idempotency_key="portal-form-launch-1",
|
||||
parameters={"applicant_name": "Ada Lovelace"},
|
||||
)
|
||||
instance = FormRuntimeService(registry).get_instance(
|
||||
session,
|
||||
principal,
|
||||
instance_id=str(launched.metadata["form_instance_id"]),
|
||||
)
|
||||
|
||||
self.assertEqual("form_submission", launched.target_ref.kind)
|
||||
self.assertEqual(service.reference, launched.service_ref)
|
||||
self.assertEqual("3", launched.metadata["form_definition_revision"])
|
||||
self.assertTrue(replay.replayed)
|
||||
self.assertEqual(launched.target_ref, replay.target_ref)
|
||||
self.assertEqual(
|
||||
(
|
||||
form.reference.owner_module,
|
||||
form.reference.object_id,
|
||||
form.reference.tenant_id,
|
||||
form.reference.version,
|
||||
),
|
||||
(
|
||||
instance.definition_ref.owner_module,
|
||||
instance.definition_ref.object_id,
|
||||
instance.definition_ref.tenant_id,
|
||||
instance.definition_ref.version,
|
||||
),
|
||||
)
|
||||
self.assertEqual(NOW, instance.definition_ref.valid_at)
|
||||
self.assertEqual(service.reference, instance.service_ref)
|
||||
self.assertEqual("Ada Lovelace", instance.values["applicant_name"])
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,17 +4,101 @@ import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderDeclaration,
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleMaturityEvidence,
|
||||
ProviderBehaviorDeclaration,
|
||||
ProviderObjectDeclaration,
|
||||
)
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||
if str(RELEASE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_ROOT))
|
||||
|
||||
from govoplan_release.catalog_entry_synthesis import validate_initial_entry_closure # noqa: E402
|
||||
from govoplan_release.catalog_entry_synthesis import ( # noqa: E402
|
||||
manifest_catalog_entry,
|
||||
validate_initial_entry_closure,
|
||||
)
|
||||
from govoplan_release.selective_catalog import apply_repo_updates # noqa: E402
|
||||
|
||||
|
||||
class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
||||
def test_catalog_entry_preserves_architecture_and_provider_declarations(
|
||||
self,
|
||||
) -> None:
|
||||
provider = ExternalProviderDeclaration(
|
||||
id="example.records",
|
||||
module_id="example",
|
||||
label="Example records",
|
||||
maturity="read",
|
||||
operations=("read",),
|
||||
objects=(
|
||||
ProviderObjectDeclaration(
|
||||
object_type="record",
|
||||
field_groups=("identity", "content"),
|
||||
authority_modes=("external_mirror",),
|
||||
default_authority_mode="external_mirror",
|
||||
),
|
||||
),
|
||||
behavior=ProviderBehaviorDeclaration(
|
||||
freshness="Reports the acquisition timestamp.",
|
||||
health="Reports source and parser health.",
|
||||
max_read_items=100,
|
||||
classifications=("internal",),
|
||||
purposes=("release contract test",),
|
||||
retention="The owning package retention policy applies.",
|
||||
),
|
||||
)
|
||||
architecture = ModuleArchitectureDeclaration(
|
||||
layer="data_reporting_integration",
|
||||
kind="integration",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_release_catalog_entry_synthesis.py",
|
||||
summary="Proves catalog metadata preservation.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md",
|
||||
summary="Defines the provider declaration contract.",
|
||||
),
|
||||
),
|
||||
known_limits=("Test-only provider.",),
|
||||
supported_authority_modes=("external_mirror",),
|
||||
owned_concepts=("example transport",),
|
||||
target_tested_providers=(provider.id,),
|
||||
)
|
||||
|
||||
entry = manifest_catalog_entry(
|
||||
manifest=ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.2.3",
|
||||
architecture=architecture,
|
||||
external_providers=(provider,),
|
||||
),
|
||||
repo="govoplan-example",
|
||||
package="govoplan-example",
|
||||
version="1.2.3",
|
||||
description=None,
|
||||
root=META_ROOT,
|
||||
repository_base="git+ssh://git@git.add-ideas.de/GovOPlaN",
|
||||
)
|
||||
|
||||
self.assertEqual("vertical_slice", entry["architecture"]["maturity"])
|
||||
self.assertEqual(
|
||||
"external_mirror",
|
||||
entry["external_providers"][0]["objects"][0][
|
||||
"default_authority_mode"
|
||||
],
|
||||
)
|
||||
|
||||
def test_selective_update_synthesizes_initial_entries_from_package_manifests(self) -> None:
|
||||
payload: dict[str, object] = {
|
||||
"core_release": {},
|
||||
|
||||
@@ -17,6 +17,34 @@ from govoplan_release import git_state # noqa: E402
|
||||
|
||||
|
||||
class ReleaseGitStateTests(unittest.TestCase):
|
||||
def test_manifest_version_does_not_confuse_interface_versions(self) -> None:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "govoplan-example"
|
||||
manifest = root / "src" / "govoplan_example" / "backend" / "manifest.py"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text(
|
||||
"\n".join(
|
||||
(
|
||||
'MODULE_VERSION = "1.2.3"',
|
||||
"manifest = ModuleManifest(",
|
||||
' id="example",',
|
||||
" version=MODULE_VERSION,",
|
||||
" provides_interfaces=(",
|
||||
' ModuleInterfaceProvider(name="example.items", version="9.8.7"),',
|
||||
" ),",
|
||||
")",
|
||||
"",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
versions = git_state.read_manifest_versions(root)
|
||||
|
||||
self.assertEqual(("1.2.3",), versions)
|
||||
|
||||
def test_git_trusts_only_the_resolved_repository_for_each_command(self) -> None:
|
||||
repository = Path("/workspace/../workspace/govoplan-core")
|
||||
completed = subprocess.CompletedProcess([], 0, "", "")
|
||||
|
||||
Reference in New Issue
Block a user