Files
microsoft--agent-framework/python/packages/durabletask/tests/test_workflow_serialization.py
T
wehub-resource-sync db620d33df
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:25 +08:00

452 lines
17 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for workflow serialization helpers.
``resolve_type`` is annotated ``type | None`` and its result flows into
``reconstruct_to_type``, which calls ``issubclass``. A non-class attribute
(function, module member, etc.) would raise ``TypeError`` there, so the
resolver must only ever return actual classes.
``deserialize_workflow_output`` reverses the per-output ``serialize_value``
encoding the shared activity applies, so typed outputs are returned as the
original objects rather than checkpoint-marker dicts.
``serialize_value`` / ``deserialize_value`` are the internal codec; the
round-trip, ``reconstruct_to_type``, and ``strip_pickle_markers`` suites below
guard the type fidelity and the trust-boundary defense that neutralizes
attacker-injected pickle/type markers before they can reach ``pickle.loads()``.
"""
import json
from collections import OrderedDict
from dataclasses import dataclass
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
Message,
WorkflowEvent,
)
from pydantic import BaseModel
from agent_framework_durabletask._workflows.serialization import (
SUBWORKFLOW_INPUT_KEY,
deserialize_value,
deserialize_workflow_event,
deserialize_workflow_output,
reconstruct_to_type,
resolve_type,
serialize_value,
serialize_workflow_event,
strip_pickle_markers,
strip_subworkflow_markers,
)
@dataclass
class _Decision:
"""Module-level dataclass so it is picklable by serialize_value."""
approved: bool
note: str
class TestResolveType:
"""Test that resolve_type only returns real classes."""
def test_resolves_a_real_class(self) -> None:
assert resolve_type("collections:OrderedDict") is OrderedDict
def test_returns_none_for_non_class_attribute(self) -> None:
# json.dumps is a function; if resolve_type returned it, issubclass()
# inside reconstruct_to_type() would raise TypeError at runtime.
assert resolve_type("json:dumps") is None
def test_returns_none_for_unknown_attribute(self) -> None:
assert resolve_type("json:DoesNotExist") is None
def test_returns_none_for_malformed_key(self) -> None:
assert resolve_type("not-a-valid-key") is None
class TestDeserializeWorkflowOutput:
"""Reconstruction of stored workflow outputs."""
def test_primitives_pass_through(self) -> None:
# Mirror the stored shape: a list of yielded outputs, JSON round-tripped.
stored = json.loads(json.dumps([serialize_value("hello"), serialize_value(42)]))
assert deserialize_workflow_output(stored) == ["hello", 42]
def test_typed_outputs_are_reconstructed(self) -> None:
# A typed object is stored as a checkpoint-marker dict; it must come back
# as the original object, not the marker dict.
decision = _Decision(approved=True, note="ok")
stored = json.loads(json.dumps([serialize_value(decision)]))
result = deserialize_workflow_output(stored)
assert result == [decision]
assert isinstance(result[0], _Decision)
def test_none_passes_through(self) -> None:
assert deserialize_workflow_output(None) is None
@dataclass
class _Approval:
"""Module-level dataclass so it is picklable by serialize_value."""
reason: str
def _roundtrip(event: WorkflowEvent) -> WorkflowEvent:
# Mirror the real path: serialize, JSON round-trip through the custom status,
# then reconstruct on the client.
return deserialize_workflow_event(json.loads(json.dumps(serialize_workflow_event(event))))
class TestWorkflowEventRoundtrip:
"""serialize_workflow_event / deserialize_workflow_event preserve event identity."""
def test_output_event_reconstructs_typed_data(self) -> None:
result = _roundtrip(WorkflowEvent("output", data=_Approval(reason="ok"), executor_id="writer"))
assert result.type == "output"
assert result.executor_id == "writer"
assert result.data == _Approval(reason="ok")
assert isinstance(result.data, _Approval)
def test_executor_completed_without_data_roundtrips_to_none(self) -> None:
result = _roundtrip(WorkflowEvent.executor_completed("reviewer"))
assert result.type == "executor_completed"
assert result.executor_id == "reviewer"
assert result.data is None
def test_iteration_tag_is_preserved(self) -> None:
# The orchestrator tags each event with its superstep before publishing.
serialized = serialize_workflow_event(WorkflowEvent.executor_invoked("writer"))
serialized["iteration"] = 3
result = deserialize_workflow_event(json.loads(json.dumps(serialized)))
assert result.type == "executor_invoked"
assert result.iteration == 3
def test_request_info_event_roundtrips(self) -> None:
event: WorkflowEvent = WorkflowEvent.request_info(
request_id="req-1",
source_executor_id="approver",
request_data=_Approval(reason="needs sign-off"),
response_type=bool,
)
result = _roundtrip(event)
assert result.type == "request_info"
assert result.request_id == "req-1"
assert result.source_executor_id == "approver"
assert result.response_type is bool
assert result.data == _Approval(reason="needs sign-off")
# Module-level test types (must be importable for checkpoint encoding roundtrip).
@dataclass
class SampleData:
"""Sample dataclass for testing checkpoint encoding roundtrip."""
name: str
value: int
class SampleModel(BaseModel):
"""Sample Pydantic model for testing checkpoint encoding roundtrip."""
title: str
count: int
@dataclass
class DataclassWithPydanticField:
"""Dataclass containing a Pydantic model field for testing nested serialization."""
label: str
model: SampleModel
class TestSerializationRoundtrip:
"""``serialize_value`` / ``deserialize_value`` round-trip the typed objects used in workflows."""
def test_roundtrip_chat_message(self) -> None:
"""Test Message survives encode → decode roundtrip."""
original = Message(role="user", contents=["Hello"])
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, Message)
assert decoded.role == "user"
def test_roundtrip_agent_executor_request(self) -> None:
"""Test AgentExecutorRequest with nested Messages roundtrips."""
original = AgentExecutorRequest(
messages=[Message(role="user", contents=["Hi"])],
should_respond=True,
)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, AgentExecutorRequest)
assert len(decoded.messages) == 1
assert isinstance(decoded.messages[0], Message)
assert decoded.should_respond is True
def test_roundtrip_agent_executor_response(self) -> None:
"""Test AgentExecutorResponse with nested AgentResponse roundtrips."""
original = AgentExecutorResponse(
executor_id="test_exec",
agent_response=AgentResponse(messages=[Message(role="assistant", contents=["Reply"])]),
full_conversation=[Message(role="assistant", contents=["Reply"])],
)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, AgentExecutorResponse)
assert decoded.executor_id == "test_exec"
assert isinstance(decoded.agent_response, AgentResponse)
def test_roundtrip_dataclass(self) -> None:
"""Test custom dataclass roundtrips."""
original = SampleData(name="test", value=42)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, SampleData)
assert decoded.name == "test"
assert decoded.value == 42
def test_roundtrip_pydantic_model(self) -> None:
"""Test Pydantic model roundtrips."""
original = SampleModel(title="Hello", count=5)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, SampleModel)
assert decoded.title == "Hello"
assert decoded.count == 5
def test_roundtrip_primitives(self) -> None:
"""Test primitives pass through unchanged."""
assert serialize_value(None) is None
assert serialize_value("hello") == "hello"
assert serialize_value(42) == 42
assert serialize_value(3.14) == 3.14
assert serialize_value(True) is True
def test_roundtrip_list_of_objects(self) -> None:
"""Test list of typed objects roundtrips."""
original = [
Message(role="user", contents=["Q"]),
Message(role="assistant", contents=["A"]),
]
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, list)
assert len(decoded) == 2
assert all(isinstance(m, Message) for m in decoded)
def test_roundtrip_dict_of_objects(self) -> None:
"""Test dict with typed values roundtrips (used for shared state)."""
original = {"count": 42, "msg": Message(role="user", contents=["Hi"])}
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert decoded["count"] == 42
assert isinstance(decoded["msg"], Message)
def test_roundtrip_dataclass_with_nested_pydantic(self) -> None:
"""Test dataclass containing a Pydantic model field roundtrips correctly.
This covers the HITL pattern where AnalysisWithSubmission (dataclass)
contains a ContentAnalysisResult (Pydantic BaseModel) field.
"""
original = DataclassWithPydanticField(label="test", model=SampleModel(title="Nested", count=99))
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, DataclassWithPydanticField)
assert decoded.label == "test"
assert isinstance(decoded.model, SampleModel)
assert decoded.model.title == "Nested"
assert decoded.model.count == 99
class TestReconstructToType:
"""Test suite for reconstruct_to_type function (used for HITL responses)."""
def test_none_returns_none(self) -> None:
"""Test that None input returns None."""
assert reconstruct_to_type(None, str) is None
def test_already_correct_type(self) -> None:
"""Test that values already of the correct type are returned as-is."""
assert reconstruct_to_type("hello", str) == "hello"
assert reconstruct_to_type(42, int) == 42
def test_non_dict_returns_original(self) -> None:
"""Test that non-dict values are returned as-is."""
assert reconstruct_to_type("hello", int) == "hello"
assert reconstruct_to_type([1, 2], dict) == [1, 2]
def test_reconstruct_pydantic_model(self) -> None:
"""Test reconstruction of Pydantic model from plain dict."""
class ApprovalResponse(BaseModel):
approved: bool
reason: str
data = {"approved": True, "reason": "Looks good"}
result = reconstruct_to_type(data, ApprovalResponse)
assert isinstance(result, ApprovalResponse)
assert result.approved is True
assert result.reason == "Looks good"
def test_reconstruct_dataclass(self) -> None:
"""Test reconstruction of dataclass from plain dict."""
@dataclass
class Feedback:
score: int
comment: str
data = {"score": 5, "comment": "Great"}
result = reconstruct_to_type(data, Feedback)
assert isinstance(result, Feedback)
assert result.score == 5
assert result.comment == "Great"
def test_reconstruct_from_checkpoint_markers(self) -> None:
"""Test that data with checkpoint markers is decoded via deserialize_value.
reconstruct_to_type is general-purpose and handles trusted checkpoint
data. Untrusted HITL callers must call strip_pickle_markers() first.
"""
original = SampleData(value=99, name="marker-test")
encoded = serialize_value(original)
result = reconstruct_to_type(encoded, SampleData)
assert isinstance(result, SampleData)
assert result.value == 99
def test_unrecognized_dict_returns_original(self) -> None:
"""Test that unrecognized dicts are returned as-is."""
@dataclass
class Unrelated:
completely_different: str
data = {"some_key": "some_value"}
result = reconstruct_to_type(data, Unrelated)
assert result == data
def test_reconstruct_strips_injected_pickle_markers(self) -> None:
"""End-to-end: strip_pickle_markers + reconstruct_to_type blocks attack.
This mirrors the real HITL flow where callers sanitize before reconstruction.
"""
malicious = {"__pickled__": "gASVDgAAAAAAAACMBHRlc3SULg==", "__type__": "builtins:str"}
sanitized = strip_pickle_markers(malicious)
result = reconstruct_to_type(sanitized, str)
assert result is None
class TestStripPickleMarkers:
"""Security tests for strip_pickle_markers — the defence-in-depth layer
that prevents untrusted HTTP input from reaching pickle.loads()."""
def test_strips_top_level_pickle_marker(self) -> None:
"""A dict containing __pickled__ must be replaced with None."""
data = {"__pickled__": "PAYLOAD", "__type__": "os:system"}
assert strip_pickle_markers(data) is None
def test_strips_top_level_type_marker_only(self) -> None:
"""Even __type__ alone (without __pickled__) must be neutralised."""
data = {"__type__": "os:system", "other": "value"}
assert strip_pickle_markers(data) is None
def test_strips_nested_pickle_marker(self) -> None:
"""Pickle markers nested inside a dict must be neutralised."""
data = {"safe": "value", "nested": {"__pickled__": "PAYLOAD", "__type__": "os:system"}}
result = strip_pickle_markers(data)
assert result == {"safe": "value", "nested": None}
def test_strips_pickle_marker_in_list(self) -> None:
"""Pickle markers inside a list element must be neutralised."""
data = [{"__pickled__": "PAYLOAD"}, "safe"]
result = strip_pickle_markers(data)
assert result == [None, "safe"]
def test_strips_deeply_nested_marker(self) -> None:
"""Deeply nested pickle markers must be neutralised."""
data = {"a": {"b": {"c": {"__pickled__": "deep"}}}}
result = strip_pickle_markers(data)
assert result == {"a": {"b": {"c": None}}}
def test_preserves_safe_dict(self) -> None:
"""Dicts without pickle markers must be left untouched."""
data = {"approved": True, "reason": "Looks good"}
assert strip_pickle_markers(data) == data
def test_preserves_primitives(self) -> None:
"""Primitive values must pass through unchanged."""
assert strip_pickle_markers("hello") == "hello"
assert strip_pickle_markers(42) == 42
assert strip_pickle_markers(None) is None
assert strip_pickle_markers(True) is True
def test_preserves_safe_list(self) -> None:
"""Lists without pickle markers must be left untouched."""
data = [1, "two", {"key": "value"}]
assert strip_pickle_markers(data) == data
def test_mixed_safe_and_malicious(self) -> None:
"""Only the malicious entries should be stripped; safe entries remain."""
data = {
"user_input": "hello",
"evil": {"__pickled__": "PAYLOAD", "__type__": "os:system"},
"count": 42,
}
result = strip_pickle_markers(data)
assert result == {"user_input": "hello", "evil": None, "count": 42}
class TestStripSubworkflowMarkers:
"""Boundary defence: a forged sub-workflow envelope in untrusted input is removed.
Only an internal child dispatch (post trust boundary) may carry the reserved
key; if untrusted client input could, it would be treated as a trusted
sub-orchestration payload and reach pickle.loads without sanitization.
"""
def test_strips_input_key(self) -> None:
data = {SUBWORKFLOW_INPUT_KEY: {"__pickled__": "evil"}, "real": 1}
assert strip_subworkflow_markers(data) == {"real": 1}
def test_strips_full_forged_envelope(self) -> None:
data = {SUBWORKFLOW_INPUT_KEY: "x"}
assert strip_subworkflow_markers(data) == {}
def test_preserves_ordinary_dict(self) -> None:
data = {"order_id": 42, "items": ["a", "b"]}
assert strip_subworkflow_markers(data) == data
def test_preserves_non_dict(self) -> None:
assert strip_subworkflow_markers("hello") == "hello"
assert strip_subworkflow_markers([1, 2]) == [1, 2]
assert strip_subworkflow_markers(None) is None