339 lines
11 KiB
Python
339 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
import unittest
|
|
|
|
from sqlalchemy import create_engine, select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from govoplan_core.auth import ApiPrincipal
|
|
from govoplan_core.core.access import PrincipalRef
|
|
from govoplan_core.core.modules import ModuleManifest
|
|
from govoplan_core.core.policy import PolicyDecision
|
|
from govoplan_core.core.registry import PlatformRegistry
|
|
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_workflow_engine.backend.contributions import (
|
|
reconcile_workflow_definition_contributions,
|
|
reset_workflow_override_to_standard,
|
|
)
|
|
from govoplan_workflow_engine.backend.db.models import (
|
|
WorkflowDefinition,
|
|
WorkflowDefinitionRevision,
|
|
WorkflowInstance,
|
|
WorkflowInstanceEvent,
|
|
WorkflowInstanceStep,
|
|
)
|
|
from govoplan_workflow_engine.backend.instance_service import start_instance
|
|
from govoplan_workflow_engine.backend.schemas import (
|
|
WorkflowDefinitionDeriveRequest,
|
|
WorkflowDefinitionUpdateRequest,
|
|
WorkflowEdge,
|
|
WorkflowGraph,
|
|
WorkflowInstanceStartRequest,
|
|
WorkflowNode,
|
|
)
|
|
from govoplan_workflow_engine.backend.service import (
|
|
WorkflowConflictError,
|
|
definition_response,
|
|
derive_definition,
|
|
update_definition,
|
|
)
|
|
|
|
|
|
def contribution_graph(*, label: str = "Approve request") -> WorkflowGraph:
|
|
return WorkflowGraph(
|
|
nodes=[
|
|
WorkflowNode(
|
|
id="start",
|
|
type="workflow.start.manual",
|
|
label="Start",
|
|
config={"input_schema_ref": ""},
|
|
),
|
|
WorkflowNode(
|
|
id="review",
|
|
type="workflow.activity",
|
|
label=label,
|
|
config={
|
|
"title": label,
|
|
"instructions": "",
|
|
"assignee": "",
|
|
"due_after": "",
|
|
},
|
|
),
|
|
WorkflowNode(
|
|
id="complete",
|
|
type="workflow.end.completed",
|
|
label="Complete",
|
|
config={"output_mapping": {}},
|
|
),
|
|
],
|
|
edges=[
|
|
WorkflowEdge(id="start-review", source="start", target="review"),
|
|
WorkflowEdge(id="review-complete", source="review", target="complete"),
|
|
],
|
|
)
|
|
|
|
|
|
def contribution(
|
|
*,
|
|
version: str = "1.0.0",
|
|
label: str = "Approve request",
|
|
) -> WorkflowDefinitionContribution:
|
|
return WorkflowDefinitionContribution(
|
|
origin_module_id="permits",
|
|
origin_module_version=version,
|
|
definition_key="application-approval",
|
|
name="Application approval",
|
|
description="Module-provided approval baseline.",
|
|
graph=contribution_graph(label=label).model_dump(mode="json"),
|
|
allow_automation=False,
|
|
metadata={"domain": "permits"},
|
|
)
|
|
|
|
|
|
def registry_for(item: WorkflowDefinitionContribution) -> PlatformRegistry:
|
|
registry = PlatformRegistry()
|
|
registry.register(
|
|
ModuleManifest(
|
|
id="permits",
|
|
name="Permits",
|
|
version=item.origin_module_version,
|
|
workflow_definitions=(item,),
|
|
)
|
|
)
|
|
registry.validate()
|
|
return registry
|
|
|
|
|
|
def principal() -> ApiPrincipal:
|
|
return ApiPrincipal(
|
|
principal=PrincipalRef(
|
|
account_id="account-1",
|
|
membership_id="membership-1",
|
|
tenant_id="tenant-1",
|
|
scopes=frozenset(
|
|
{
|
|
"workflow:definition:read",
|
|
"workflow:definition:write",
|
|
"workflow:instance:admin",
|
|
}
|
|
),
|
|
),
|
|
account=object(),
|
|
user=object(),
|
|
)
|
|
|
|
|
|
class _DefinitionPolicy:
|
|
def resolve_definition_action(self, *, request):
|
|
return PolicyDecision(
|
|
allowed=(
|
|
request.action in {"view", "derive", "reuse"}
|
|
and request.allow_reuse
|
|
),
|
|
reason=None,
|
|
)
|
|
|
|
|
|
class _PolicyRegistry:
|
|
def has_capability(self, name: str) -> bool:
|
|
return name == "policy.definitionGovernance"
|
|
|
|
def capability(self, name: str):
|
|
if not self.has_capability(name):
|
|
raise KeyError(name)
|
|
return _DefinitionPolicy()
|
|
|
|
|
|
class WorkflowContributionTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(
|
|
self.engine,
|
|
tables=[
|
|
WorkflowDefinition.__table__,
|
|
WorkflowDefinitionRevision.__table__,
|
|
WorkflowInstance.__table__,
|
|
WorkflowInstanceStep.__table__,
|
|
WorkflowInstanceEvent.__table__,
|
|
],
|
|
)
|
|
self.Session = sessionmaker(bind=self.engine)
|
|
self.session: Session = self.Session()
|
|
|
|
def tearDown(self) -> None:
|
|
self.session.close()
|
|
Base.metadata.drop_all(
|
|
self.engine,
|
|
tables=[
|
|
WorkflowInstanceEvent.__table__,
|
|
WorkflowInstanceStep.__table__,
|
|
WorkflowInstance.__table__,
|
|
WorkflowDefinitionRevision.__table__,
|
|
WorkflowDefinition.__table__,
|
|
],
|
|
)
|
|
self.engine.dispose()
|
|
|
|
def test_reconciliation_is_idempotent_and_does_not_auto_activate_updates(self) -> None:
|
|
first = reconcile_workflow_definition_contributions(
|
|
self.session,
|
|
registry=registry_for(contribution()),
|
|
)
|
|
repeated = reconcile_workflow_definition_contributions(
|
|
self.session,
|
|
registry=registry_for(contribution()),
|
|
)
|
|
upgraded = reconcile_workflow_definition_contributions(
|
|
self.session,
|
|
registry=registry_for(
|
|
contribution(version="1.1.0", label="Approve corrected request")
|
|
),
|
|
)
|
|
self.session.commit()
|
|
|
|
definition = self.session.scalar(select(WorkflowDefinition))
|
|
self.assertIsNotNone(definition)
|
|
assert definition is not None
|
|
self.assertEqual(1, first["created"])
|
|
self.assertEqual(1, repeated["unchanged"])
|
|
self.assertEqual(1, upgraded["updated"])
|
|
self.assertEqual(2, definition.current_revision)
|
|
self.assertEqual(1, definition.active_revision)
|
|
self.assertEqual("permits", definition.standard_origin_module_id)
|
|
self.assertEqual("1.1.0", definition.standard_origin_module_version)
|
|
self.assertEqual(2, len(definition.revisions))
|
|
self.assertNotEqual(
|
|
definition.revisions[0].contribution_hash,
|
|
definition.revisions[1].contribution_hash,
|
|
)
|
|
|
|
def test_standard_is_immutable_and_reset_archives_only_the_override(self) -> None:
|
|
reconcile_workflow_definition_contributions(
|
|
self.session,
|
|
registry=registry_for(contribution()),
|
|
)
|
|
baseline = self.session.scalar(select(WorkflowDefinition))
|
|
assert baseline is not None
|
|
with self.assertRaisesRegex(WorkflowConflictError, "immutable"):
|
|
update_definition(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
definition_id=baseline.id,
|
|
actor_id="account-1",
|
|
payload=WorkflowDefinitionUpdateRequest(
|
|
name=baseline.name,
|
|
graph=contribution_graph(),
|
|
expected_revision=baseline.current_revision,
|
|
scope_type="system",
|
|
),
|
|
)
|
|
override = derive_definition(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
actor_id="account-1",
|
|
principal=principal(),
|
|
registry=_PolicyRegistry(),
|
|
source_definition_id=baseline.id,
|
|
payload=WorkflowDefinitionDeriveRequest(
|
|
name="Tenant approval override",
|
|
scope_type="tenant",
|
|
),
|
|
)
|
|
initial = definition_response(
|
|
self.session,
|
|
override,
|
|
principal=principal(),
|
|
registry=_PolicyRegistry(),
|
|
)
|
|
self.assertIsNotNone(initial.standard)
|
|
assert initial.standard is not None
|
|
self.assertFalse(initial.standard.update_available)
|
|
|
|
reconcile_workflow_definition_contributions(
|
|
self.session,
|
|
registry=registry_for(
|
|
contribution(version="1.1.0", label="Approve corrected request")
|
|
),
|
|
)
|
|
drifted = definition_response(
|
|
self.session,
|
|
override,
|
|
principal=principal(),
|
|
registry=_PolicyRegistry(),
|
|
)
|
|
self.assertIsNotNone(drifted.standard)
|
|
assert drifted.standard is not None
|
|
self.assertTrue(drifted.standard.update_available)
|
|
|
|
restored = reset_workflow_override_to_standard(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
definition_id=override.id,
|
|
actor_id="account-1",
|
|
)
|
|
self.session.commit()
|
|
|
|
self.assertEqual(baseline.id, restored.id)
|
|
self.assertEqual("archived", override.status)
|
|
self.assertEqual("active", baseline.status)
|
|
self.assertEqual(
|
|
baseline.id,
|
|
override.metadata_["standard_reset"]["baseline_definition_id"],
|
|
)
|
|
|
|
def test_missing_requirements_fail_closed_without_creating_a_baseline(self) -> None:
|
|
item = replace(
|
|
contribution(),
|
|
required_capabilities=("permits.approvalAction",),
|
|
)
|
|
result = reconcile_workflow_definition_contributions(
|
|
self.session,
|
|
registry=registry_for(item),
|
|
)
|
|
|
|
self.assertEqual(1, result["blocked"])
|
|
self.assertIsNone(self.session.scalar(select(WorkflowDefinition)))
|
|
self.assertEqual(
|
|
["capability:permits.approvalAction"],
|
|
result["items"][0]["missing"], # type: ignore[index]
|
|
)
|
|
|
|
def test_domain_contribution_runs_without_the_editor_module(self) -> None:
|
|
item = replace(
|
|
contribution(),
|
|
scope_type="tenant",
|
|
inherit_to_lower_scopes=False,
|
|
)
|
|
registry = registry_for(item)
|
|
result = reconcile_workflow_definition_contributions(
|
|
self.session,
|
|
registry=registry,
|
|
tenant_ids=("tenant-1",),
|
|
)
|
|
definition = self.session.scalar(select(WorkflowDefinition))
|
|
assert definition is not None
|
|
|
|
instance, replayed = start_instance(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
definition_id=definition.id,
|
|
actor_id="account-1",
|
|
principal=principal(),
|
|
registry=registry,
|
|
payload=WorkflowInstanceStartRequest(
|
|
idempotency_key="headless-domain-run",
|
|
input={"request_id": "request-1"},
|
|
),
|
|
)
|
|
|
|
self.assertEqual(1, result["created"])
|
|
self.assertFalse(replayed)
|
|
self.assertEqual("waiting", instance.status)
|
|
self.assertEqual(definition.revisions[0].id, instance.definition_revision_id)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|