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
452 lines
17 KiB
Python
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
|