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
398 lines
13 KiB
C#
398 lines
13 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
|
|
|
/// <summary>
|
|
/// 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) ───────────────────
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void NoReturnDefinition_AnythingIsValid(string? returnDefinition)
|
|
{
|
|
var result = ReturnValueValidator.Validate("{\"anything\":1}", returnDefinition);
|
|
Assert.True(result.IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoReturnDefinition_NullResult_IsValid()
|
|
{
|
|
var result = ReturnValueValidator.Validate(null, null);
|
|
Assert.True(result.IsValid);
|
|
}
|
|
|
|
// ── Happy path: result matches the declared object shape ──────────────────
|
|
|
|
[Fact]
|
|
public void ResultMatchingDefinition_IsValid()
|
|
{
|
|
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);
|
|
|
|
Assert.True(result.IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResultWithListOfScalars_TypeChecked_IsValid()
|
|
{
|
|
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);
|
|
}
|
|
|
|
// ── Scalar / shape mismatches must be reported ────────────────────────────
|
|
|
|
[Fact]
|
|
public void ResultMissingDeclaredField_IsInvalid()
|
|
{
|
|
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);
|
|
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("totalUnits", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResultFieldWrongType_IsInvalid()
|
|
{
|
|
const string def = """{"type":"object","properties":{"totalUnits":{"type":"integer"}},"required":["totalUnits"]}""";
|
|
const string json = """{"totalUnits":"not-a-number"}""";
|
|
|
|
var result = ReturnValueValidator.Validate(json, def);
|
|
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("totalUnits", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void NullResultWhenStructureRequired_IsInvalid()
|
|
{
|
|
const string def = """{"type":"object","properties":{"siteName":{"type":"string"}},"required":["siteName"]}""";
|
|
|
|
var result = ReturnValueValidator.Validate(null, def);
|
|
|
|
Assert.False(result.IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void NonObjectResultWhenStructureRequired_IsInvalid()
|
|
{
|
|
const string def = """{"type":"object","properties":{"siteName":{"type":"string"}},"required":["siteName"]}""";
|
|
|
|
var result = ReturnValueValidator.Validate("42", def);
|
|
|
|
Assert.False(result.IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListFieldGivenNonArray_IsInvalid()
|
|
{
|
|
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);
|
|
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("lines", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void MalformedReturnDefinition_IsInvalid()
|
|
{
|
|
var result = ReturnValueValidator.Validate("{\"x\":1}", "%%% not json %%%");
|
|
|
|
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);
|
|
}
|
|
|
|
// FIX 3: scalar return schema validates scalar return values ──────────────
|
|
// (Guards the intentional ParameterValidator/ReturnValueValidator asymmetry:
|
|
// ReturnValueValidator must NOT short-circuit on non-object schema types.)
|
|
|
|
[Fact]
|
|
public void ScalarStringReturnSchema_ValidatesScalarStringReturn()
|
|
{
|
|
// A {"type":"string"} return schema must accept a bare JSON string.
|
|
var result = ReturnValueValidator.Validate("\"hello\"", """{"type":"string"}""");
|
|
Assert.True(result.IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void ScalarIntegerReturnSchema_ValidatesScalarIntegerReturn()
|
|
{
|
|
var result = ReturnValueValidator.Validate("42", """{"type":"integer"}""");
|
|
Assert.True(result.IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void ScalarStringReturnSchema_RejectsIntegerReturn()
|
|
{
|
|
var result = ReturnValueValidator.Validate("42", """{"type":"string"}""");
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("String", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void ScalarBooleanReturnSchema_ValidatesBooleanReturn()
|
|
{
|
|
var result = ReturnValueValidator.Validate("true", """{"type":"boolean"}""");
|
|
Assert.True(result.IsValid);
|
|
}
|
|
|
|
// FIX 2: recursion depth guard on Validate ─────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ValidateExceedingDepthCeiling_AddsDepthError_DoesNotThrow()
|
|
{
|
|
// Build a schema programmatically (bypassing Parse) with 34 levels of
|
|
// nesting to exceed the ceiling of 32. Validate must add an error and
|
|
// return, NOT stack overflow.
|
|
//
|
|
// Parse prevents creating a >32-level schema from stored JSON, but
|
|
// InboundApiSchema is a public type constructable in code, so Validate
|
|
// must guard independently.
|
|
var deepSchema = BuildProgrammaticSchema(34);
|
|
|
|
var json = BuildDeeplyNestedValue(34);
|
|
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
|
|
|
var errors = new System.Collections.Generic.List<string>();
|
|
// Must not throw — adds a depth error to the list instead.
|
|
deepSchema.Validate(doc.RootElement, string.Empty, errors);
|
|
|
|
Assert.NotEmpty(errors);
|
|
Assert.Contains("nesting too deep", errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constructs an <see cref="InboundApiSchema"/> with <paramref name="depth"/>
|
|
/// levels of object-nesting programmatically (bypassing <c>Parse</c>) to
|
|
/// exercise the Validate depth ceiling independently of the Parse ceiling.
|
|
/// </summary>
|
|
private static InboundApiSchema BuildProgrammaticSchema(int depth)
|
|
{
|
|
InboundApiSchema inner = new() { Type = "string" };
|
|
for (var i = 0; i < depth; i++)
|
|
{
|
|
inner = new InboundApiSchema
|
|
{
|
|
Type = "object",
|
|
Fields = [new InboundApiSchemaField("a", Required: false, inner)],
|
|
};
|
|
}
|
|
return inner;
|
|
}
|
|
|
|
private static string BuildDeeplyNestedValue(int depth)
|
|
{
|
|
var value = "\"leaf\"";
|
|
for (var i = 0; i < depth; i++)
|
|
{
|
|
value = "{\"a\":" + value + "}";
|
|
}
|
|
return value;
|
|
}
|
|
}
|