feat(inbound-api): nested Object/List extended-type validation (#13)

Object/List parameters and return values were shape-validated only (object vs
array), with no field-level/nested type checks — type-wrong nested data passed
inbound validation and failed only at script runtime. Add recursive type
validation (declared Object field types, List element type, scalars at any depth)
with path-qualified errors, symmetric across ParameterValidator and ReturnValueValidator.

Both validators now parse the canonical JSON Schema definition format (the
Central UI / MigrateParametersToJsonSchema output) via a shared recursive engine,
Commons.Types.InboundApi.InboundApiSchema, instead of the legacy flat
[{name,type}] array which they could not even deserialize from migrated rows.
The legacy flat-array form is still accepted on read for transition safety.
Undeclared fields are rejected at every level (consistent with the existing
top-level unexpected-parameter rejection); a present-but-null value satisfies
any type, only absence of a required field is an error.
This commit is contained in:
Joseph Doherty
2026-06-15 15:04:28 -04:00
parent 3032faac0d
commit 4b6187c853
8 changed files with 982 additions and 286 deletions
@@ -3,10 +3,27 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// <summary>
/// WP-2: Tests for parameter validation — type checking, required fields, extended type system.
/// WP-2 / InboundAPI-M2.6: tests for parameter validation — type checking,
/// required fields, the extended type system, and RECURSIVE (nested Object /
/// List element) type validation with path-qualified errors.
///
/// <para>
/// Definitions are expressed as JSON Schema (the canonical persisted format
/// produced by the Central UI / migration). The validator also accepts the
/// legacy flat-array form; that backward-compat path is covered by the final
/// region.
/// </para>
/// </summary>
public class ParameterValidatorTests
{
private static JsonElement Body(string json)
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
// ── No / empty definitions ────────────────────────────────────────────────
[Fact]
public void NoDefinitions_NoBody_ReturnsValid()
{
@@ -16,21 +33,27 @@ public class ParameterValidatorTests
}
[Fact]
public void EmptyDefinitions_ReturnsValid()
public void EmptyObjectSchema_ReturnsValid()
{
var result = ParameterValidator.Validate(null, """{"type":"object","properties":{}}""");
Assert.True(result.IsValid);
}
[Fact]
public void EmptyLegacyArray_ReturnsValid()
{
var result = ParameterValidator.Validate(null, "[]");
Assert.True(result.IsValid);
}
// ── Required / body shape ──────────────────────────────────────────────────
[Fact]
public void RequiredParameterMissing_ReturnsInvalid()
{
var definitions = JsonSerializer.Serialize(new[]
{
new { Name = "value", Type = "Integer", Required = true }
});
const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}""";
var result = ParameterValidator.Validate(null, definitions);
var result = ParameterValidator.Validate(null, def);
Assert.False(result.IsValid);
Assert.Contains("Missing required parameter", result.ErrorMessage);
}
@@ -38,136 +61,307 @@ public class ParameterValidatorTests
[Fact]
public void BodyNotObject_ReturnsInvalid()
{
var definitions = JsonSerializer.Serialize(new[]
{
new { Name = "value", Type = "String", Required = true }
});
const string def = """{"type":"object","properties":{"value":{"type":"string"}},"required":["value"]}""";
using var doc = JsonDocument.Parse("\"just a string\"");
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
var result = ParameterValidator.Validate(Body("\"just a string\""), def);
Assert.False(result.IsValid);
Assert.Contains("must be a JSON object", result.ErrorMessage);
}
[Theory]
[InlineData("Boolean", "true", true)]
[InlineData("Integer", "42", (long)42)]
[InlineData("Float", "3.14", 3.14)]
[InlineData("String", "\"hello\"", "hello")]
public void ValidTypeCoercion_Succeeds(string type, string jsonValue, object expected)
{
var definitions = JsonSerializer.Serialize(new[]
{
new { Name = "val", Type = type, Required = true }
});
using var doc = JsonDocument.Parse($"{{\"val\": {jsonValue}}}");
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
Assert.True(result.IsValid);
Assert.Equal(expected, result.Parameters["val"]);
}
[Fact]
public void WrongType_ReturnsInvalid()
{
var definitions = JsonSerializer.Serialize(new[]
{
new { Name = "count", Type = "Integer", Required = true }
});
using var doc = JsonDocument.Parse("{\"count\": \"not a number\"}");
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
Assert.False(result.IsValid);
Assert.Contains("must be an Integer", result.ErrorMessage);
}
[Fact]
public void ObjectType_Parsed()
{
var definitions = JsonSerializer.Serialize(new[]
{
new { Name = "data", Type = "Object", Required = true }
});
using var doc = JsonDocument.Parse("{\"data\": {\"key\": \"value\"}}");
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
Assert.True(result.IsValid);
Assert.IsType<Dictionary<string, object?>>(result.Parameters["data"]);
}
[Fact]
public void ListType_Parsed()
{
var definitions = JsonSerializer.Serialize(new[]
{
new { Name = "items", Type = "List", Required = true }
});
using var doc = JsonDocument.Parse("{\"items\": [1, 2, 3]}");
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
Assert.True(result.IsValid);
Assert.IsType<List<object?>>(result.Parameters["items"]);
}
[Fact]
public void OptionalParameter_MissingBody_ReturnsValid()
{
var definitions = JsonSerializer.Serialize(new[]
{
new { Name = "optional", Type = "String", Required = false }
});
const string def = """{"type":"object","properties":{"optional":{"type":"string"}}}""";
var result = ParameterValidator.Validate(null, definitions);
var result = ParameterValidator.Validate(null, def);
Assert.True(result.IsValid);
}
// ── Scalar coercion ────────────────────────────────────────────────────────
[Theory]
[InlineData("boolean", "true", true)]
[InlineData("integer", "42", (long)42)]
[InlineData("number", "3.14", 3.14)]
[InlineData("string", "\"hello\"", "hello")]
public void ValidTypeCoercion_Succeeds(string type, string jsonValue, object expected)
{
var def = "{\"type\":\"object\",\"properties\":{\"val\":{\"type\":\"" + type + "\"}},\"required\":[\"val\"]}";
var result = ParameterValidator.Validate(Body($"{{\"val\": {jsonValue}}}"), def);
Assert.True(result.IsValid);
Assert.Equal(expected, result.Parameters["val"]);
}
[Fact]
public void WrongScalarType_ReturnsInvalid()
{
const string def = """{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}""";
var result = ParameterValidator.Validate(Body("{\"count\": \"not a number\"}"), def);
Assert.False(result.IsValid);
Assert.Contains("'count'", result.ErrorMessage);
Assert.Contains("Integer", result.ErrorMessage);
}
[Fact]
public void UnknownType_ReturnsInvalid()
{
var definitions = JsonSerializer.Serialize(new[]
{
new { Name = "val", Type = "CustomType", Required = true }
});
const string def = """{"type":"object","properties":{"val":{"type":"customtype"}},"required":["val"]}""";
using var doc = JsonDocument.Parse("{\"val\": \"test\"}");
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
var result = ParameterValidator.Validate(Body("{\"val\": \"test\"}"), def);
Assert.False(result.IsValid);
Assert.Contains("Unknown parameter type", result.ErrorMessage);
Assert.Contains("unknown declared type", result.ErrorMessage);
}
// --- InboundAPI-010: unexpected top-level body fields must be reported so
// callers get feedback on typo'd parameter names instead of silent ignore. ---
// ── Object / List shape + materialization ──────────────────────────────────
[Fact]
public void UnexpectedBodyField_ReturnsInvalid()
public void ObjectType_NoDeclaredFields_ShapeOnly_Materialized()
{
var definitions = JsonSerializer.Serialize(new[]
{
new { Name = "value", Type = "Integer", Required = true }
});
const string def = """{"type":"object","properties":{"data":{"type":"object"}},"required":["data"]}""";
var result = ParameterValidator.Validate(Body("{\"data\": {\"key\": \"value\"}}"), def);
Assert.True(result.IsValid);
Assert.IsType<Dictionary<string, object?>>(result.Parameters["data"]);
}
[Fact]
public void ListType_NoDeclaredElement_ShapeOnly_Materialized()
{
const string def = """{"type":"object","properties":{"items":{"type":"array"}},"required":["items"]}""";
var result = ParameterValidator.Validate(Body("{\"items\": [1, 2, 3]}"), def);
Assert.True(result.IsValid);
Assert.IsType<List<object?>>(result.Parameters["items"]);
}
// ── Undeclared / unexpected fields (rejected, recursively) ─────────────────
[Fact]
public void UnexpectedTopLevelField_ReturnsInvalid()
{
const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}""";
// "valeu" is a typo for "value"; the caller must be told, not ignored.
using var doc = JsonDocument.Parse("{\"value\": 1, \"valeu\": 2}");
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
var result = ParameterValidator.Validate(Body("{\"value\": 1, \"valeu\": 2}"), def);
Assert.False(result.IsValid);
Assert.Contains("valeu", result.ErrorMessage);
Assert.Contains("not a declared field", result.ErrorMessage);
}
[Fact]
public void OnlyDefinedFields_StillValid()
public void OnlyDeclaredFields_StillValid()
{
// Regression guard: a body containing exactly the defined parameters
// must continue to validate.
var definitions = JsonSerializer.Serialize(new[]
{
new { Name = "value", Type = "Integer", Required = true }
});
const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}""";
using var doc = JsonDocument.Parse("{\"value\": 1}");
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
var result = ParameterValidator.Validate(Body("{\"value\": 1}"), def);
Assert.True(result.IsValid);
Assert.Equal((long)1, result.Parameters["value"]);
}
[Fact]
public void UndeclaredNestedField_ReturnsInvalid_PathQualified()
{
const string def = """
{"type":"object","properties":{
"order":{"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]}
},"required":["order"]}
""";
var result = ParameterValidator.Validate(
Body("""{"order":{"id":1,"bogus":2}}"""), def);
Assert.False(result.IsValid);
Assert.Contains("order.bogus", result.ErrorMessage);
Assert.Contains("not a declared field", result.ErrorMessage);
}
// ── Nested validation: the M2.6 core ───────────────────────────────────────
private const string NestedDef = """
{
"type":"object",
"properties":{
"order":{
"type":"object",
"properties":{
"id":{"type":"integer"},
"customer":{
"type":"object",
"properties":{"name":{"type":"string"},"vip":{"type":"boolean"}},
"required":["name"]
},
"items":{
"type":"array",
"items":{
"type":"object",
"properties":{"sku":{"type":"string"},"quantity":{"type":"integer"}},
"required":["sku","quantity"]
}
}
},
"required":["id","customer","items"]
}
},
"required":["order"]
}
""";
[Fact]
public void ValidNestedPayload_Passes()
{
const string body = """
{"order":{
"id":7,
"customer":{"name":"Acme","vip":true},
"items":[
{"sku":"A1","quantity":3},
{"sku":"B2","quantity":1}
]
}}
""";
var result = ParameterValidator.Validate(Body(body), NestedDef);
Assert.True(result.IsValid);
}
[Fact]
public void WrongScalarTwoLevelsDeep_ReturnsInvalid_WithExactPath()
{
// order.customer.vip declared boolean, given a string.
const string body = """
{"order":{
"id":7,
"customer":{"name":"Acme","vip":"yes"},
"items":[]
}}
""";
var result = ParameterValidator.Validate(Body(body), NestedDef);
Assert.False(result.IsValid);
Assert.Contains("'order.customer.vip'", result.ErrorMessage);
Assert.Contains("Boolean", result.ErrorMessage);
}
[Fact]
public void WrongScalarInsideListElement_ReturnsInvalid_WithElementIndexInPath()
{
// order.items[1].quantity declared integer, given a string.
const string body = """
{"order":{
"id":7,
"customer":{"name":"Acme"},
"items":[
{"sku":"A1","quantity":3},
{"sku":"B2","quantity":"lots"}
]
}}
""";
var result = ParameterValidator.Validate(Body(body), NestedDef);
Assert.False(result.IsValid);
Assert.Contains("'order.items[1].quantity'", result.ErrorMessage);
Assert.Contains("Integer", result.ErrorMessage);
}
[Fact]
public void ListElementWrongShape_ReturnsInvalid_WithElementIndexInPath()
{
// order.items[0] declared object, given a scalar.
const string body = """
{"order":{
"id":7,
"customer":{"name":"Acme"},
"items":[ 42 ]
}}
""";
var result = ParameterValidator.Validate(Body(body), NestedDef);
Assert.False(result.IsValid);
Assert.Contains("'order.items[0]'", result.ErrorMessage);
Assert.Contains("Object", result.ErrorMessage);
}
[Fact]
public void MissingRequiredNestedField_ReturnsInvalid_PathQualified()
{
// order.customer.name is required but absent.
const string body = """
{"order":{
"id":7,
"customer":{"vip":false},
"items":[]
}}
""";
var result = ParameterValidator.Validate(Body(body), NestedDef);
Assert.False(result.IsValid);
Assert.Contains("missing required field", result.ErrorMessage);
Assert.Contains("'order.customer.name'", result.ErrorMessage);
}
// ── Empty / null edge cases ────────────────────────────────────────────────
[Fact]
public void EmptyList_AgainstTypedElement_Passes()
{
const string body = """
{"order":{"id":7,"customer":{"name":"Acme"},"items":[]}}
""";
var result = ParameterValidator.Validate(Body(body), NestedDef);
Assert.True(result.IsValid);
}
[Fact]
public void NullForOptionalNestedScalar_Passes()
{
// order.customer.vip is optional; explicit null is accepted.
const string body = """
{"order":{
"id":7,
"customer":{"name":"Acme","vip":null},
"items":[]
}}
""";
var result = ParameterValidator.Validate(Body(body), NestedDef);
Assert.True(result.IsValid);
}
[Fact]
public void NullForRequiredNestedScalar_Passes()
{
// A PRESENT-but-null required field satisfies the type — only ABSENCE
// of a required field is an error (consistent with return-side policy).
const string body = """
{"order":{
"id":null,
"customer":{"name":"Acme"},
"items":[]
}}
""";
var result = ParameterValidator.Validate(Body(body), NestedDef);
Assert.True(result.IsValid);
}
// ── Legacy flat-array backward-compat ──────────────────────────────────────
[Fact]
public void LegacyFlatArrayDefinition_StillAccepted()
{
const string def = """[{"name":"count","type":"Integer","required":true}]""";
var ok = ParameterValidator.Validate(Body("{\"count\":5}"), def);
Assert.True(ok.IsValid);
Assert.Equal((long)5, ok.Parameters["count"]);
var bad = ParameterValidator.Validate(Body("{\"count\":\"nope\"}"), def);
Assert.False(bad.IsValid);
Assert.Contains("'count'", bad.ErrorMessage);
}
}
@@ -1,13 +1,19 @@
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// <summary>
/// InboundAPI-014: tests for return-value validation against a method's
/// <c>ReturnDefinition</c>. Previously the script's return value was serialized
/// verbatim with no checking against the declared return structure.
/// InboundAPI-014 / InboundAPI-M2.6: tests for return-value validation against a
/// method's <c>ReturnDefinition</c>. Mirrors <see cref="ParameterValidatorTests"/>
/// (shared recursive engine) — RECURSIVE nested Object / List-element type
/// validation with path-qualified errors.
///
/// <para>
/// Definitions are expressed as JSON Schema (the canonical persisted format);
/// the legacy flat-array form is still accepted (final region).
/// </para>
/// </summary>
public class ReturnValueValidatorTests
{
// --- No definition → no validation (backward compatible) ---
// ── No definition → no validation (backward compatible) ───────────────────
[Theory]
[InlineData(null)]
@@ -26,12 +32,17 @@ public class ReturnValueValidatorTests
Assert.True(result.IsValid);
}
// --- Happy path: result matches the declared field shape ---
// ── Happy path: result matches the declared object shape ──────────────────
[Fact]
public void ResultMatchingDefinition_IsValid()
{
const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]""";
const string def = """
{"type":"object","properties":{
"siteName":{"type":"string"},
"totalUnits":{"type":"integer"}
},"required":["siteName","totalUnits"]}
""";
const string json = """{"siteName":"Site Alpha","totalUnits":14250}""";
var result = ReturnValueValidator.Validate(json, def);
@@ -40,22 +51,31 @@ public class ReturnValueValidatorTests
}
[Fact]
public void ResultWithListField_ShapeChecked_IsValid()
public void ResultWithListOfScalars_TypeChecked_IsValid()
{
const string def = """[{"name":"lines","type":"List"}]""";
const string json = """{"lines":[{"lineName":"Line-1","units":8200}]}""";
const string def = """
{"type":"object","properties":{
"codes":{"type":"array","items":{"type":"integer"}}
}}
""";
const string json = """{"codes":[1,2,3]}""";
var result = ReturnValueValidator.Validate(json, def);
Assert.True(result.IsValid);
}
// --- Mismatches must be reported ---
// ── Scalar / shape mismatches must be reported ────────────────────────────
[Fact]
public void ResultMissingDeclaredField_IsInvalid()
{
const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]""";
const string def = """
{"type":"object","properties":{
"siteName":{"type":"string"},
"totalUnits":{"type":"integer"}
},"required":["siteName","totalUnits"]}
""";
const string json = """{"siteName":"Site Alpha"}""";
var result = ReturnValueValidator.Validate(json, def);
@@ -67,7 +87,7 @@ public class ReturnValueValidatorTests
[Fact]
public void ResultFieldWrongType_IsInvalid()
{
const string def = """[{"name":"totalUnits","type":"Integer"}]""";
const string def = """{"type":"object","properties":{"totalUnits":{"type":"integer"}},"required":["totalUnits"]}""";
const string json = """{"totalUnits":"not-a-number"}""";
var result = ReturnValueValidator.Validate(json, def);
@@ -79,7 +99,7 @@ public class ReturnValueValidatorTests
[Fact]
public void NullResultWhenStructureRequired_IsInvalid()
{
const string def = """[{"name":"siteName","type":"String"}]""";
const string def = """{"type":"object","properties":{"siteName":{"type":"string"}},"required":["siteName"]}""";
var result = ReturnValueValidator.Validate(null, def);
@@ -89,7 +109,7 @@ public class ReturnValueValidatorTests
[Fact]
public void NonObjectResultWhenStructureRequired_IsInvalid()
{
const string def = """[{"name":"siteName","type":"String"}]""";
const string def = """{"type":"object","properties":{"siteName":{"type":"string"}},"required":["siteName"]}""";
var result = ReturnValueValidator.Validate("42", def);
@@ -99,7 +119,7 @@ public class ReturnValueValidatorTests
[Fact]
public void ListFieldGivenNonArray_IsInvalid()
{
const string def = """[{"name":"lines","type":"List"}]""";
const string def = """{"type":"object","properties":{"lines":{"type":"array","items":{"type":"object"}}}}""";
const string json = """{"lines":"not-a-list"}""";
var result = ReturnValueValidator.Validate(json, def);
@@ -115,4 +135,173 @@ public class ReturnValueValidatorTests
Assert.False(result.IsValid);
}
// ── Nested validation: the M2.6 core (production-report shape) ─────────────
private const string ReportDef = """
{
"type":"object",
"properties":{
"siteName":{"type":"string"},
"totalUnits":{"type":"integer"},
"lines":{
"type":"array",
"items":{
"type":"object",
"properties":{
"lineName":{"type":"string"},
"units":{"type":"integer"},
"efficiency":{"type":"number"}
},
"required":["lineName","units"]
}
}
},
"required":["siteName","totalUnits","lines"]
}
""";
[Fact]
public void ValidNestedReturn_Passes()
{
const string json = """
{
"siteName":"Site Alpha",
"totalUnits":14250,
"lines":[
{"lineName":"Line-1","units":8200,"efficiency":92.5},
{"lineName":"Line-2","units":6050,"efficiency":88.1}
]
}
""";
var result = ReturnValueValidator.Validate(json, ReportDef);
Assert.True(result.IsValid);
}
[Fact]
public void WrongScalarInsideListElement_IsInvalid_WithElementIndexInPath()
{
// lines[1].units declared integer, given a string.
const string json = """
{
"siteName":"Site Alpha",
"totalUnits":14250,
"lines":[
{"lineName":"Line-1","units":8200},
{"lineName":"Line-2","units":"lots"}
]
}
""";
var result = ReturnValueValidator.Validate(json, ReportDef);
Assert.False(result.IsValid);
Assert.Contains("'lines[1].units'", result.ErrorMessage);
Assert.Contains("Integer", result.ErrorMessage);
}
[Fact]
public void WrongListElementType_IsInvalid_WithElementIndexInPath()
{
// lines[0] declared object, given a scalar.
const string json = """
{
"siteName":"Site Alpha",
"totalUnits":14250,
"lines":[ 7 ]
}
""";
var result = ReturnValueValidator.Validate(json, ReportDef);
Assert.False(result.IsValid);
Assert.Contains("'lines[0]'", result.ErrorMessage);
Assert.Contains("Object", result.ErrorMessage);
}
[Fact]
public void MissingRequiredNestedField_IsInvalid_PathQualified()
{
// lines[0].units is required but absent.
const string json = """
{
"siteName":"Site Alpha",
"totalUnits":14250,
"lines":[ {"lineName":"Line-1"} ]
}
""";
var result = ReturnValueValidator.Validate(json, ReportDef);
Assert.False(result.IsValid);
Assert.Contains("missing required field", result.ErrorMessage);
Assert.Contains("'lines[0].units'", result.ErrorMessage);
}
[Fact]
public void UndeclaredNestedField_IsInvalid_PathQualified()
{
// lines[0].bogus is not declared on the line-item schema.
const string json = """
{
"siteName":"Site Alpha",
"totalUnits":14250,
"lines":[ {"lineName":"Line-1","units":1,"bogus":true} ]
}
""";
var result = ReturnValueValidator.Validate(json, ReportDef);
Assert.False(result.IsValid);
Assert.Contains("'lines[0].bogus'", result.ErrorMessage);
Assert.Contains("not a declared field", result.ErrorMessage);
}
// ── Empty / null edge cases ────────────────────────────────────────────────
[Fact]
public void EmptyListAgainstTypedElement_Passes()
{
const string json = """{"siteName":"S","totalUnits":0,"lines":[]}""";
var result = ReturnValueValidator.Validate(json, ReportDef);
Assert.True(result.IsValid);
}
[Fact]
public void EmptyObjectSchema_AnythingIsValid()
{
const string def = """{"type":"object","properties":{}}""";
var result = ReturnValueValidator.Validate("""{"whatever":1}""", def);
Assert.True(result.IsValid);
}
[Fact]
public void NullOptionalNestedScalar_Passes()
{
// lines[0].efficiency is optional; explicit null is accepted.
const string json = """
{
"siteName":"S",
"totalUnits":1,
"lines":[ {"lineName":"L1","units":1,"efficiency":null} ]
}
""";
var result = ReturnValueValidator.Validate(json, ReportDef);
Assert.True(result.IsValid);
}
// ── Legacy flat-array backward-compat ──────────────────────────────────────
[Fact]
public void LegacyFlatArrayDefinition_StillAccepted()
{
const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]""";
var ok = ReturnValueValidator.Validate("""{"siteName":"A","totalUnits":1}""", def);
Assert.True(ok.IsValid);
var bad = ReturnValueValidator.Validate("""{"siteName":"A","totalUnits":"x"}""", def);
Assert.False(bad.IsValid);
Assert.Contains("totalUnits", bad.ErrorMessage);
}
}