411d0c043b
- legacy flat-array "required":"false" (string) now treated as optional (matches migration) - depth ceiling (32) on InboundApiSchema Parse/Validate recursion — guards against stack-overflow from a deeply-nested stored schema (Parse throws->400, Validate adds error) - DocOptions.MaxDepth=128 so the application-level structural guard fires before the System.Text.Json reader ceiling (each schema level = ~3 JSON reader levels) - comment the intentional ParameterValidator/ReturnValueValidator early-return asymmetry - note intentional datetime->string legacy collapse in NormalizeType - tests: legacy string-false optional, parse/validate depth ceiling, scalar return schema
441 lines
16 KiB
C#
441 lines
16 KiB
C#
using System.Text.Json;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
|
|
|
/// <summary>
|
|
/// 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()
|
|
{
|
|
var result = ParameterValidator.Validate(null, null);
|
|
Assert.True(result.IsValid);
|
|
Assert.Empty(result.Parameters);
|
|
}
|
|
|
|
[Fact]
|
|
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()
|
|
{
|
|
const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}""";
|
|
|
|
var result = ParameterValidator.Validate(null, def);
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("Missing required parameter", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void BodyNotObject_ReturnsInvalid()
|
|
{
|
|
const string def = """{"type":"object","properties":{"value":{"type":"string"}},"required":["value"]}""";
|
|
|
|
var result = ParameterValidator.Validate(Body("\"just a string\""), def);
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("must be a JSON object", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void OptionalParameter_MissingBody_ReturnsValid()
|
|
{
|
|
const string def = """{"type":"object","properties":{"optional":{"type":"string"}}}""";
|
|
|
|
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()
|
|
{
|
|
const string def = """{"type":"object","properties":{"val":{"type":"customtype"}},"required":["val"]}""";
|
|
|
|
var result = ParameterValidator.Validate(Body("{\"val\": \"test\"}"), def);
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("unknown declared type", result.ErrorMessage);
|
|
}
|
|
|
|
// ── Object / List shape + materialization ──────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ObjectType_NoDeclaredFields_ShapeOnly_Materialized()
|
|
{
|
|
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.
|
|
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 OnlyDeclaredFields_StillValid()
|
|
{
|
|
const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}""";
|
|
|
|
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);
|
|
}
|
|
|
|
// FIX 1: legacy "required":"false" string → field is optional ─────────────
|
|
|
|
[Theory]
|
|
[InlineData("""[{"name":"opt","type":"String","required":"false"}]""")]
|
|
[InlineData("""[{"name":"opt","type":"String","required":"False"}]""")]
|
|
[InlineData("""[{"name":"opt","type":"String","required":"FALSE"}]""")]
|
|
public void LegacyFlatArray_RequiredStringFalse_FieldIsOptional(string def)
|
|
{
|
|
// An absent field whose "required" is the string "false" (any case)
|
|
// must be treated as optional — consistent with the SQL migration's
|
|
// LOWER(...) <> 'false' comparison that produced these rows.
|
|
var result = ParameterValidator.Validate(null, def);
|
|
Assert.True(result.IsValid, $"Expected optional field to be valid when absent; error: {result.ErrorMessage}");
|
|
}
|
|
|
|
[Fact]
|
|
public void LegacyFlatArray_RequiredStringFalse_FieldPresentAndTypedCorrectly_Passes()
|
|
{
|
|
const string def = """[{"name":"opt","type":"String","required":"false"}]""";
|
|
|
|
var result = ParameterValidator.Validate(Body("{\"opt\":\"hello\"}"), def);
|
|
Assert.True(result.IsValid);
|
|
}
|
|
|
|
// FIX 2: recursion depth guard on Parse ───────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Builds a JSON Schema string with <paramref name="depth"/> levels of nested
|
|
/// object-in-properties nesting. Each level wraps the previous in an object
|
|
/// with a single property "a". The result exceeds the Parse ceiling when
|
|
/// depth > 32.
|
|
/// </summary>
|
|
private static string BuildDeeplyNestedSchema(int depth)
|
|
{
|
|
// Inner-most: a scalar
|
|
var schema = "{\"type\":\"string\"}";
|
|
for (var i = 0; i < depth; i++)
|
|
{
|
|
schema = "{\"type\":\"object\",\"properties\":{\"a\":" + schema + "}}";
|
|
}
|
|
return schema;
|
|
}
|
|
|
|
[Fact]
|
|
public void SchemaAtDepthCeiling_ParsesSuccessfully()
|
|
{
|
|
// Exactly 32 levels of nesting should parse without throwing.
|
|
var def = BuildDeeplyNestedSchema(32);
|
|
var schema = InboundApiSchema.Parse(def);
|
|
Assert.NotNull(schema);
|
|
}
|
|
|
|
[Fact]
|
|
public void SchemaExceedingDepthCeiling_ThrowsJsonException_NotStackOverflow()
|
|
{
|
|
// 33 levels exceeds the ceiling → JsonException (clean 400 via the
|
|
// caller's try/catch), NOT a StackOverflowException.
|
|
var def = BuildDeeplyNestedSchema(33);
|
|
Assert.Throws<System.Text.Json.JsonException>(() => InboundApiSchema.Parse(def));
|
|
}
|
|
|
|
[Fact]
|
|
public void SchemaExceedingDepthCeiling_ParameterValidator_ReturnsInvalid()
|
|
{
|
|
// End-to-end: ParameterValidator wraps Parse in try/catch(JsonException)
|
|
// → the caller gets Invalid rather than an unhandled exception.
|
|
var def = BuildDeeplyNestedSchema(33);
|
|
var result = ParameterValidator.Validate(Body("{\"a\":\"x\"}"), def);
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("Invalid parameter definitions", result.ErrorMessage);
|
|
}
|
|
}
|