Files
lda-wf/tests/wf_api/test_input_expression_validation.py
T

463 lines
15 KiB
Python

from __future__ import annotations
import pytest
from wf_api.input_expressions import (
validate_and_project_input_expression,
validate_schema_references,
)
from wf_core.models.input_bindings import InputExpressionBinding
def _concat_schema() -> dict[str, object]:
return {
"type": "object",
"properties": {
"items": {"type": "array", "items": {"type": "string"}},
"separator": {"type": "string"},
},
"required": ["items"],
}
def _expression(payload: dict[str, object]) -> InputExpressionBinding:
return InputExpressionBinding.model_validate(
{"target": "items", "expression": payload}
)
def _path_expression_projection(
source_schema: dict[str, object],
target_fragment: dict[str, object],
):
return validate_and_project_input_expression(
InputExpressionBinding.model_validate(
{"target": "value", "expression": {"kind": "path", "path": "state.foo"}}
).expression,
target_schema={
"type": "object",
"properties": {"value": target_fragment},
},
input_schema={"type": "object", "properties": {}},
state_schema={
"type": "object",
"properties": {"foo": source_schema},
},
target_location=("value",),
)
@pytest.mark.parametrize(
("source_schema", "target_schema", "expected_message"),
[
({"const": 1}, {"type": "string"}, "incompatible"),
({"enum": [1]}, {"type": "string"}, "incompatible"),
({"type": "string"}, {"enum": ["ok"]}, "unsupported"),
],
)
def test_finite_constraints_and_types_are_not_overaccepted(
source_schema: dict[str, object],
target_schema: dict[str, object],
expected_message: str,
) -> None:
with pytest.raises(ValueError, match=expected_message):
_path_expression_projection(source_schema, target_schema)
@pytest.mark.parametrize(
"target_schema",
[{"type": "integer"}, {"type": "number"}],
)
def test_integer_source_is_assignable_to_integer_and_number_targets(
target_schema: dict[str, object],
) -> None:
_path_expression_projection({"type": "integer"}, target_schema)
def test_constrained_integer_source_remains_assignable_to_unconstrained_integer() -> (
None
):
_path_expression_projection(
{"type": "integer", "minimum": 1},
{"type": "integer"},
)
def test_array_bounds_are_checked_during_source_assignability() -> None:
with pytest.raises(ValueError, match="incompatible"):
_path_expression_projection(
{"type": "array", "items": {"type": "string"}, "minItems": 0},
{
"type": "array",
"items": {"type": "string"},
"minItems": 1,
},
)
with pytest.raises(ValueError, match="incompatible"):
_path_expression_projection(
{"type": "array", "items": {"type": "string"}, "maxItems": 4},
{
"type": "array",
"items": {"type": "string"},
"maxItems": 3,
},
)
def test_object_additional_properties_are_compared_in_both_directions() -> None:
with pytest.raises(ValueError, match="incompatible"):
_path_expression_projection(
{
"type": "object",
"properties": {"extra": {"type": "integer"}},
},
{
"type": "object",
"additionalProperties": {"type": "string"},
},
)
with pytest.raises(ValueError, match="incompatible"):
_path_expression_projection(
{
"type": "object",
"additionalProperties": {"type": "integer"},
},
{
"type": "object",
"properties": {"extra": {"type": "string"}},
},
)
@pytest.mark.parametrize(
"source_additional",
[
{"$ref": "https://example.com/schema.json"},
{"$ref": "#/$defs/Missing"},
{
"$ref": "#/$defs/Node",
},
],
)
def test_unresolvable_additional_property_refs_do_not_fall_back_to_target_schema(
source_additional: dict[str, object],
) -> None:
state_schema: dict[str, object] = {
"type": "object",
"additionalProperties": source_additional,
}
if source_additional == {"$ref": "#/$defs/Node"}:
state_schema["$defs"] = {
"Node": {
"type": "object",
"properties": {"next": {"$ref": "#/$defs/Node"}},
}
}
with pytest.raises(ValueError, match="reference|unsupported|cyclic|unresolved"):
validate_and_project_input_expression(
InputExpressionBinding.model_validate(
{
"target": "value",
"expression": {"kind": "path", "path": "state.foo"},
}
).expression,
target_schema={
"type": "object",
"properties": {"value": {"type": "string"}},
},
input_schema={"type": "object", "properties": {}},
state_schema=state_schema,
target_location=("value",),
)
def test_cyclic_local_schema_normalization_fails_closed() -> None:
with pytest.raises(ValueError, match="cyclic|depth|unsupported"):
validate_and_project_input_expression(
InputExpressionBinding.model_validate(
{
"target": "value",
"expression": {"kind": "object", "fields": {}},
}
).expression,
target_schema={
"type": "object",
"properties": {"value": {"$ref": "#/$defs/Node"}},
"$defs": {
"Node": {
"type": "object",
"properties": {"next": {"$ref": "#/$defs/Node"}},
}
},
},
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
target_location=("value",),
)
def test_reference_validation_allows_composition_inside_resolved_definition() -> None:
validate_schema_references(
{
"$ref": "#/$defs/OptionalText",
"$defs": {
"OptionalText": {
"anyOf": [{"type": "string"}, {"type": "null"}],
}
},
},
label="source schema",
)
def test_reference_validation_checks_references_inside_composition() -> None:
with pytest.raises(ValueError, match="unsupported reference"):
validate_schema_references(
{
"anyOf": [
{"type": "string"},
{"$ref": "https://example.com/external.json"},
]
},
label="source schema",
)
def test_literal_expression_validates_against_exact_array_position() -> None:
projection = validate_and_project_input_expression(
_expression(
{
"kind": "array",
"items": [
{"kind": "literal", "value": "hello"},
{"kind": "literal", "value": "world"},
],
}
).expression,
target_schema=_concat_schema(),
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
target_location=("items",),
label="bindings[0].expression",
)
assert projection.deferred_paths == ()
def test_literal_expression_rejects_value_for_exact_array_position() -> None:
with pytest.raises(ValueError, match=r"items\[0\]"):
validate_and_project_input_expression(
_expression(
{
"kind": "array",
"items": [{"kind": "literal", "value": 3}],
}
).expression,
target_schema=_concat_schema(),
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
target_location=("items",),
label="bindings[0].expression",
)
def test_literal_expression_honors_common_validation_keywords() -> None:
target_schema = {
"type": "object",
"properties": {"value": {"type": "string", "minLength": 2}},
}
expression = InputExpressionBinding.model_validate(
{"target": "value", "expression": {"kind": "literal", "value": "ok"}}
).expression
validate_and_project_input_expression(
expression,
target_schema=target_schema,
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
target_location=("value",),
)
invalid = InputExpressionBinding.model_validate(
{"target": "value", "expression": {"kind": "literal", "value": "x"}}
).expression
with pytest.raises(ValueError, match="literal does not satisfy"):
validate_and_project_input_expression(
invalid,
target_schema=target_schema,
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
target_location=("value",),
)
def test_boolean_child_schema_fails_as_unsupported_instead_of_crashing() -> None:
with pytest.raises(ValueError, match="unsupported schema comparison"):
_path_expression_projection(
{"type": "object", "properties": {"value": True}},
{
"type": "object",
"additionalProperties": {"type": "string"},
},
)
def test_path_expression_projects_missing_state_schema_from_target_position() -> None:
projection = validate_and_project_input_expression(
_expression(
{
"kind": "array",
"items": [
{"kind": "path", "path": "state.foo"},
{"kind": "literal", "value": "wowcool"},
],
}
).expression,
target_schema=_concat_schema(),
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
target_location=("items",),
label="bindings[0].expression",
)
assert projection.state_schema["properties"]["foo"] == {"type": "string"}
def test_path_expression_projects_missing_input_schema_from_target_position() -> None:
projection = validate_and_project_input_expression(
_expression(
{
"kind": "array",
"items": [{"kind": "path", "path": "input.title"}],
}
).expression,
target_schema=_concat_schema(),
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
target_location=("items",),
label="bindings[0].expression",
)
assert projection.input_schema["properties"]["title"] == {"type": "string"}
def test_known_incompatible_path_schema_is_rejected() -> None:
with pytest.raises(ValueError, match="state.foo"):
validate_and_project_input_expression(
_expression(
{
"kind": "array",
"items": [{"kind": "path", "path": "state.foo"}],
}
).expression,
target_schema=_concat_schema(),
input_schema={"type": "object", "properties": {}},
state_schema={
"type": "object",
"properties": {"foo": {"type": "integer"}},
},
target_location=("items",),
label="bindings[0].expression",
)
def test_nested_local_refs_are_compared_with_their_definition_scope() -> None:
target_schema = {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"$ref": "#/$defs/Text"},
}
},
"$defs": {"Text": {"type": "string"}},
}
state_schema = {
"type": "object",
"properties": {"foo": {"$ref": "#/$defs/Text"}},
"$defs": {"Text": {"type": "string"}},
}
projection = validate_and_project_input_expression(
_expression(
{
"kind": "array",
"items": [{"kind": "path", "path": "state.foo"}],
}
).expression,
target_schema=target_schema,
input_schema={"type": "object", "properties": {}},
state_schema=state_schema,
target_location=("items",),
)
assert projection.deferred_paths == ()
def test_unconstrained_source_does_not_claim_a_typed_target() -> None:
with pytest.raises(ValueError, match="unsupported schema comparison"):
validate_and_project_input_expression(
_expression(
{
"kind": "array",
"items": [{"kind": "path", "path": "state.foo"}],
}
).expression,
target_schema=_concat_schema(),
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {"foo": {}}},
target_location=("items",),
)
def test_context_path_is_deferred_to_runtime_validation() -> None:
projection = validate_and_project_input_expression(
_expression(
{
"kind": "array",
"items": [{"kind": "path", "path": "context.message"}],
}
).expression,
target_schema=_concat_schema(),
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
target_location=("items",),
label="bindings[0].expression",
)
assert projection.deferred_paths == ("context.message",)
@pytest.mark.parametrize("keyword", ["allOf", "anyOf", "oneOf", "if", "then", "else"])
def test_unsupported_schema_composition_fails_closed(keyword: str) -> None:
value: object = (
[{"type": "string"}]
if keyword in {"allOf", "anyOf", "oneOf"}
else {"type": "string"}
)
target_schema = {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {keyword: value},
}
},
}
with pytest.raises(ValueError, match="unsupported"):
validate_and_project_input_expression(
_expression(
{
"kind": "array",
"items": [{"kind": "literal", "value": "value"}],
}
).expression,
target_schema=target_schema,
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
target_location=("items",),
label="bindings[0].expression",
)