fix(inbound-api): M2.6 review nits — legacy required default, recursion depth guard, return-validator comment (#13)
- 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
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
@@ -364,4 +365,76 @@ public class ParameterValidatorTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -304,4 +306,92 @@ public class ReturnValueValidatorTests
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user