fix: harden Python workflow client boundary

This commit is contained in:
lda
2026-08-31 19:51:43 +07:00 Verified
parent b377311a6e
commit 4f946a35ef
35 changed files with 1257 additions and 302 deletions
+154 -2
View File
@@ -5,8 +5,14 @@ from typing import Any, cast
import httpx
import pytest
from wf_client import App, CapabilitySummary, Page
from wf_client.errors import InvalidResponse
import wf_client
from wf_client import App, CapabilitySummary, Page, WorkflowClientError
from wf_client.errors import (
CapabilityNotFound,
InvalidResponse,
ProtocolError,
TransportError,
)
from wf_client.protocols import WorkflowClientPort
from wf_platform import CapabilityRef
@@ -92,6 +98,152 @@ def test_from_http_jsonrpc_is_lazy(monkeypatch: pytest.MonkeyPatch) -> None:
assert calls == []
def test_package_does_not_export_internal_port_or_codecs() -> None:
assert not hasattr(wf_client, "WorkflowClientPort")
assert not hasattr(wf_client, "DecodedRunResult")
assert not hasattr(wf_client, "decode_run_result")
@pytest.mark.asyncio
async def test_http_app_translates_connection_failure_to_public_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fail_post(*args: object, **kwargs: object) -> httpx.Response:
raise httpx.ConnectError("connection refused")
monkeypatch.setattr(httpx.AsyncClient, "post", fail_post)
app = App.from_http_jsonrpc("http://unreachable.test/rpc")
with pytest.raises(WorkflowClientError) as raised:
await app.capability("app.default.search")
assert isinstance(raised.value, TransportError)
assert "connection refused" in str(raised.value)
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", ["http", "json", "json-array"])
async def test_http_app_translates_http_and_json_failures(
monkeypatch: pytest.MonkeyPatch,
failure: str,
) -> None:
async def fail_post(*args: object, **kwargs: object) -> httpx.Response:
request = httpx.Request("POST", "http://test/rpc")
if failure == "http":
return httpx.Response(503, request=request)
if failure == "json-array":
return httpx.Response(200, request=request, json=[])
return httpx.Response(200, request=request, content=b"not-json")
monkeypatch.setattr(httpx.AsyncClient, "post", fail_post)
app = App.from_http_jsonrpc("http://test/rpc")
with pytest.raises(WorkflowClientError) as raised:
await app.capability("app.default.search")
expected_type = ProtocolError if failure == "json-array" else TransportError
assert isinstance(raised.value, expected_type)
assert "workflow.capabilities.inspect" in str(raised.value)
@pytest.mark.asyncio
async def test_http_app_translates_known_workflow_protocol_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def error_post(*args: object, **kwargs: object) -> httpx.Response:
return httpx.Response(
200,
request=httpx.Request("POST", "http://test/rpc"),
json={
"jsonrpc": "2.0",
"id": "request",
"error": {
"code": 5000,
"message": "Workflow operation failed",
"data": {
"code": "capability_not_found",
"message": "unknown capability app.default.search",
},
},
},
)
monkeypatch.setattr(httpx.AsyncClient, "post", error_post)
app = App.from_http_jsonrpc("http://test/rpc")
with pytest.raises(WorkflowClientError) as raised:
await app.capability("app.default.search")
assert isinstance(raised.value, CapabilityNotFound)
assert "unknown capability" in str(raised.value)
@pytest.mark.asyncio
async def test_http_app_preserves_unknown_protocol_error_details(
monkeypatch: pytest.MonkeyPatch,
) -> None:
data = {"code": "future_workflow_error", "message": "future detail", "retry": 3}
async def error_post(*args: object, **kwargs: object) -> httpx.Response:
return httpx.Response(
200,
request=httpx.Request("POST", "http://test/rpc"),
json={
"jsonrpc": "2.0",
"id": "request",
"error": {
"code": 5999,
"message": "Future workflow error",
"data": data,
},
},
)
monkeypatch.setattr(httpx.AsyncClient, "post", error_post)
app = App.from_http_jsonrpc("http://test/rpc")
with pytest.raises(ProtocolError) as raised:
await app.capability("app.default.search")
assert raised.value.code == 5999
assert raised.value.message == "Future workflow error"
assert raised.value.data == data
@pytest.mark.asyncio
async def test_workflow_rejects_mismatched_inspected_artifact_identity() -> None:
class ArtifactPort(_Port):
async def inspect_artifact(self, **params: Any) -> object:
return {
"id": "other",
"version": 2,
"title": "Other",
"kind": "workflow",
"description": None,
"input_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"outcomes": ["ok"],
"plan": {
"name": "other",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"outcomes": ["ok"],
"start": "done",
"nodes": [{"id": "done", "type": "end", "outcome": "ok"}],
"edges": [],
},
"required_capabilities": [],
"workflow_dependencies": {},
"created_from_catalog_version": None,
}
app = App._from_port(cast(WorkflowClientPort, ArtifactPort()))
with pytest.raises(InvalidResponse, match="workflow.artifacts.inspect"):
await app.workflow("report", version=1)
@pytest.mark.asyncio
async def test_capability_discovery_returns_rich_page() -> None:
page = await _app().capabilities(query="search", limit=10)
+77 -10
View File
@@ -6,6 +6,7 @@ import pytest
from wf_authoring import WorkflowBuilder
from wf_client import App, ArtifactRef, EditableWorkflow, RemoteCapability
from wf_client.errors import InvalidResponse
from wf_client.protocols import WorkflowClientPort
from wf_platform import CapabilityRef
@@ -20,6 +21,7 @@ class FakePort:
"workflow_dependencies": {},
}
self.inspect_artifact_result: dict[str, Any] | None = None
self.create_artifact_result: dict[str, Any] | None = None
async def validate_artifact_plan(self, **params: Any) -> object:
self.calls.append(("validate_artifact_plan", params))
@@ -27,7 +29,11 @@ class FakePort:
async def create_artifact_from_plan(self, **params: Any) -> object:
self.calls.append(("create_artifact_from_plan", params))
return {"artifact_id": params["artifact_id"], "version": params["version"], "saved": True}
return self.create_artifact_result or {
"artifact_id": params["artifact_id"],
"version": params["version"],
"saved": True,
}
async def inspect_artifact(self, **params: Any) -> object:
self.calls.append(("inspect_artifact", params))
@@ -43,13 +49,22 @@ def valid_plan(version: int = 1) -> dict[str, Any]:
"kind": "workflow",
"description": None,
"input_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {"value": {"type": "string"}}},
"output_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
"outcomes": ["ok"],
"plan": {
"name": "report",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {"value": {"type": "string"}}},
"output_schema": {"type": "object", "properties": {"value": {"type": "string"}}},
"state_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
"output_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
"outcomes": ["ok"],
"output": [{"path": "state.value", "target": "value"}],
"start": "done",
@@ -75,9 +90,7 @@ def remote_plan_without_schema_snapshots(version: int = 1) -> dict[str, Any]:
{"id": "done", "type": "end", "outcome": "ok"},
]
payload["plan"]["start"] = "remote"
payload["plan"]["edges"] = [
{"from": "remote", "outcome": "ok", "to": "done"}
]
payload["plan"]["edges"] = [{"from": "remote", "outcome": "ok", "to": "done"}]
payload["required_capabilities"] = [
{
"ref": {"source": "app.default", "capability_key": "remote"},
@@ -155,19 +168,73 @@ async def test_edit_and_save_inspects_exact_saved_version() -> None:
graph = await app.edit_workflow("report", version=1)
assert isinstance(graph, WorkflowBuilder)
assert isinstance(graph, EditableWorkflow)
assert all(hasattr(graph, name) for name in ("when", "choose", "match", "foreach", "interrupt", "end", "connect", "set_entry_point"))
assert all(
hasattr(graph, name)
for name in (
"when",
"choose",
"match",
"foreach",
"interrupt",
"end",
"connect",
"set_entry_point",
)
)
port.inspect_artifact_result = valid_plan(version=2)
saved = await graph.save(version=2)
create = next(params for operation, params in port.calls if operation == "create_artifact_from_plan")
create = next(
params
for operation, params in port.calls
if operation == "create_artifact_from_plan"
)
assert create["plan"] == valid_plan(version=1)["plan"]
inspect = [params for operation, params in port.calls if operation == "inspect_artifact"][-1]
inspect = [
params for operation, params in port.calls if operation == "inspect_artifact"
][-1]
assert inspect == {"artifact_id": "report", "version": 2}
assert saved.ref == ArtifactRef("report", 2)
assert str(saved.workflow.output[0].target) == "value"
@pytest.mark.asyncio
async def test_save_rejects_mismatched_create_acknowledgement() -> None:
port = FakePort()
graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
"report",
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
)
graph.set_entry_point(graph.end("ok", id="done"))
port.create_artifact_result = {
"artifact_id": "other",
"version": 2,
"saved": True,
}
with pytest.raises(InvalidResponse, match="workflow.artifacts.create_from_plan"):
await graph.save(version=2)
@pytest.mark.asyncio
async def test_save_rejects_mismatched_exact_inspection() -> None:
port = FakePort()
graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
"report",
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
)
graph.set_entry_point(graph.end("ok", id="done"))
port.inspect_artifact_result = valid_plan(version=3)
with pytest.raises(InvalidResponse, match="workflow.artifacts.inspect"):
await graph.save(version=2)
@pytest.mark.asyncio
async def test_editable_artifact_without_schema_snapshots_remains_saveable() -> None:
port = FakePort()
+69 -4
View File
@@ -36,14 +36,18 @@ def _inspect_payload() -> dict[str, Any]:
class _Port:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
self.result_qualified_name = "app.default.search"
self.result_source_id = "app.default"
self.result_kind = "node_spec"
self.result_deployment_id: str | None = None
async def call_capability(self, **params: Any) -> object:
self.calls.append(params)
return {
"qualified_name": "app.default.search",
"source_id": "app.default",
"kind": "node_spec",
"deployment_id": None,
"qualified_name": self.result_qualified_name,
"source_id": self.result_source_id,
"kind": self.result_kind,
"deployment_id": self.result_deployment_id,
"outcome": "ok",
"output": {"results": ["one"]},
"diagnostics": [],
@@ -118,6 +122,67 @@ async def test_remote_capability_rejects_mixed_payload_forms() -> None:
await capability({"query": "workflow"}, query="again")
@pytest.mark.asyncio
async def test_remote_capability_rejects_mismatched_call_source() -> None:
port = _Port()
port.result_source_id = "other.source"
capability = RemoteCapability(
_port=cast(WorkflowClientPort, port),
ref=CapabilityRef.parse("app.default.search"),
qualified_name="app.default.search",
description=None,
input_schema={"type": "object"},
output_schema={"type": "object"},
outcomes=("ok",),
is_async=False,
)
with pytest.raises(InvalidResponse, match="workflow.capabilities.call"):
await capability({})
@pytest.mark.asyncio
async def test_node_capability_rejects_unexpected_result_deployment() -> None:
port = _Port()
port.result_deployment_id = "unexpected"
capability = RemoteCapability(
_port=cast(WorkflowClientPort, port),
ref=CapabilityRef.parse("app.default.search"),
qualified_name="app.default.search",
description=None,
input_schema={"type": "object"},
output_schema={"type": "object"},
outcomes=("ok",),
is_async=False,
)
with pytest.raises(InvalidResponse, match="workflow.capabilities.call"):
await capability.call({}, deployment_id="ignored-by-node-spec")
@pytest.mark.asyncio
async def test_wrapper_capability_requires_exact_result_deployment() -> None:
port = _Port()
port.result_qualified_name = "workflow.report.v1"
port.result_source_id = "workflow"
port.result_kind = "wrapper_artifact"
port.result_deployment_id = "other.deployment"
capability = RemoteCapability(
_port=cast(WorkflowClientPort, port),
ref=CapabilityRef(source=SourceRef.parse("workflow"), name="report.v1"),
qualified_name="workflow.report.v1",
description=None,
input_schema={"type": "object"},
output_schema={"type": "object"},
outcomes=("ok",),
is_async=False,
_kind="wrapper_artifact",
)
with pytest.raises(InvalidResponse, match="workflow.capabilities.call"):
await capability.call({}, deployment_id="report.production")
def test_remote_capability_rejects_invalid_inspected_schema() -> None:
with pytest.raises(InvalidResponse, match="invalid JSON Schema"):
RemoteCapability(
+56 -1
View File
@@ -5,8 +5,9 @@ from typing import Any, cast
import pytest
from wf_artifacts import WorkflowArtifact as ArtifactModel
from wf_client import DeploymentRequired, WorkflowClientPort
from wf_client import DeploymentRequired
from wf_client.errors import DeploymentNotRunnable, InvalidResponse
from wf_client.protocols import WorkflowClientPort
from wf_client.workflows import WorkflowArtifact
from wf_core import Workflow
@@ -193,6 +194,16 @@ async def test_artifact_deploy_rejects_wrong_created_deployment_id() -> None:
await artifact.deploy("report.production")
@pytest.mark.asyncio
async def test_artifact_deploy_rejects_wrong_created_artifact_identity() -> None:
artifact = _artifact()
port = cast(_FakePort, artifact._port)
port.save_result["artifact_version"] = 2
with pytest.raises(InvalidResponse, match="workflow.deployments.save"):
await artifact.deploy("report.production")
@pytest.mark.asyncio
async def test_artifact_deploy_rejects_wrong_inspected_deployment_id() -> None:
artifact = _artifact()
@@ -310,3 +321,47 @@ async def test_deployment_run_preserves_server_error_and_diagnostics() -> None:
assert captured.value.error == "dependency check failed"
assert captured.value.outcome == "rejected"
assert captured.value.diagnostics[0].code == "missing_source"
@pytest.mark.asyncio
async def test_artifact_snapshot_defensively_copies_nested_models() -> None:
artifact = _artifact()
exposed_artifact = artifact.artifact
exposed_workflow = artifact.workflow
exposed_artifact.id = "mutated"
exposed_artifact.plan["name"] = "mutated"
exposed_workflow.name = "mutated"
assert artifact.ref.artifact_id == "report"
assert artifact.inspect().name == "report"
assert artifact.edit().name == "report"
@pytest.mark.asyncio
async def test_deployment_snapshot_defensively_copies_model_and_diagnostics() -> None:
artifact = _artifact()
port = cast(_FakePort, artifact._port)
port.validation_result["diagnostics"] = [
{
"severity": "warning",
"code": "drift",
"logical_ref": "app.default",
"bound_source": "company.production",
"message": "original",
"repair_hint": None,
}
]
deployment = await artifact.deploy("report.production")
exposed_model = deployment.model
exposed_diagnostics = deployment.diagnostics
exposed_model.id = "mutated"
exposed_model.bindings = []
exposed_diagnostics[0].message = "mutated"
assert deployment.deployment_id == "report.production"
assert deployment.bindings == {"app.default": "company.production"}
assert deployment.diagnostics[0].message == "original"
await deployment.run({})
assert port.calls[-1][1]["deployment_id"] == "report.production"
+3 -2
View File
@@ -126,7 +126,8 @@ def test_repr_does_not_materialize_an_unbounded_iterable() -> None:
def test_all_rich_objects_render_without_port_access() -> None:
port = cast(WorkflowClientPort, _port())
raw_port = _port()
port = cast(WorkflowClientPort, raw_port)
diagnostic = WorkflowDiagnostic("error", "bad", "state.x", "broken")
local = ValidationReport()
objects = [
@@ -153,4 +154,4 @@ def test_all_rich_objects_render_without_port_access() -> None:
},
)
)
assert port.calls == []
assert raw_port.calls == []
+74 -1
View File
@@ -4,8 +4,9 @@ from typing import Any, cast
import pytest
from wf_client import App, Run, WorkflowClientPort
from wf_client import App, Run
from wf_client.errors import DeploymentNotRunnable, InvalidResponse
from wf_client.protocols import WorkflowClientPort
def _payload(
@@ -135,6 +136,22 @@ async def test_resume_rejects_mismatched_result_id() -> None:
await run.resume({"approved": True})
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ["refresh", "resume"])
async def test_run_lifecycle_rejects_mismatched_deployment_identity(
operation: str,
) -> None:
port = _Port()
port.resume_payload["deployment_id"] = "other.deployment"
run = Run.from_payload(cast(WorkflowClientPort, port), _payload())
with pytest.raises(InvalidResponse, match="workflow.runs"):
if operation == "refresh":
await run.refresh()
else:
await run.resume({"approved": True})
@pytest.mark.asyncio
async def test_malformed_interrupt_route_is_invalid_response() -> None:
payload = _payload()
@@ -180,3 +197,59 @@ async def test_trace_rejects_invalid_bounds_before_io() -> None:
with pytest.raises(ValueError):
await run.trace(limit=101)
assert port.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
("field", "value"),
[
("run_id", "other-run"),
("deployment_id", "other.deployment"),
("trace_start", 1),
("trace_limit", 26),
],
)
async def test_trace_rejects_mismatched_identity_or_page(
field: str,
value: object,
) -> None:
port = _Port()
port.trace_payload[field] = value
run = Run.from_payload(cast(WorkflowClientPort, port), _payload())
with pytest.raises(InvalidResponse, match="workflow.runs.trace"):
await run.trace(start=0, limit=25)
@pytest.mark.asyncio
async def test_run_snapshot_defensively_copies_nested_public_values() -> None:
port = _Port()
payload = _payload()
payload["output"] = {"nested": {"value": "original"}}
payload["diagnostics"] = [
{
"severity": "warning",
"code": "drift",
"logical_ref": "app.default",
"bound_source": "company.production",
"message": "original",
"repair_hint": None,
}
]
run = Run.from_payload(cast(WorkflowClientPort, port), payload)
exposed_output = run.output
exposed_interrupt = run.interrupt
exposed_diagnostics = run.diagnostics
assert exposed_output is not None
assert exposed_interrupt is not None
exposed_output["nested"]["value"] = "mutated"
exposed_interrupt.payload["question"] = "mutated"
exposed_diagnostics[0].message = "mutated"
assert run.output == {"nested": {"value": "original"}}
assert run.interrupt is not None
assert run.interrupt.payload == {"question": "approve?"}
assert run.diagnostics[0].message == "original"
await run.resume({"approved": True})
assert port.calls[-1][1]["run_id"] == "run-1"