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:
Joseph Doherty
2026-06-15 15:18:44 -04:00
parent 4b6187c853
commit 411d0c043b
5 changed files with 236 additions and 20 deletions
@@ -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 &gt; 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);
}
}