fix(m9/T32b): resolve $ref in InboundAPI runtime validators (no deploy-passes/runtime-400); diamond test; ref-annotation message

This commit is contained in:
Joseph Doherty
2026-06-18 12:16:39 -04:00
parent 26e2cdef23
commit 71d5722692
9 changed files with 424 additions and 25 deletions
@@ -219,4 +219,37 @@ public class InboundApiSchemaRefTests
Assert.Null(result.Schema);
Assert.Empty(result.UnresolvedRefs);
}
// ── Diamond: two sibling refs to the SAME name resolve (not a cycle) ───────
[Fact]
public void ParseWithRefs_DiamondSiblingRefs_ResolveBothNotFalseCycle()
{
// Two sibling properties BOTH reference {"$ref":"lib:Shared"}. The shared
// name is popped from the cycle guard after each subtree is resolved, so the
// SECOND sibling must resolve too — a diamond is NOT a cycle and must not
// trip the guard. Regression for the finally-pop semantics in ResolveLibRef.
const string shared = """{"type":"object","properties":{"v":{"type":"integer"}},"required":["v"]}""";
const string json =
"""{"type":"object","properties":{"left":{"$ref":"lib:Shared"},"right":{"$ref":"lib:Shared"}}}""";
var result = InboundApiSchema.ParseWithRefs(
json,
name => name == "Shared" ? shared : null);
// Neither sibling should be reported unresolved — both resolved cleanly.
Assert.Empty(result.UnresolvedRefs);
Assert.NotNull(result.Schema);
Assert.Equal(2, result.Schema!.Fields.Count);
foreach (var sibling in new[] { "left", "right" })
{
var field = Assert.Single(result.Schema.Fields, f => f.Name == sibling);
Assert.Equal("object", field.Schema.Type);
var inner = Assert.Single(field.Schema.Fields);
Assert.Equal("v", inner.Name);
Assert.True(inner.Required);
Assert.Equal("integer", inner.Schema.Type);
}
}
}
@@ -0,0 +1,150 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// <summary>
/// M9-T32b runtime regression: the InboundAPI RUNTIME validators
/// (<see cref="ParameterValidator"/> / <see cref="ReturnValueValidator"/>) must
/// resolve <c>{"$ref":"lib:Name"}</c> library references through the same seam the
/// deploy-time path uses. Before this fix the runtime validators called the
/// single-arg <c>InboundApiSchema.Parse(json)</c> with NO resolver, so a schema
/// containing a <c>$ref</c> threw <see cref="JsonException"/> and every runtime
/// invocation of the method returned a 400 ("Invalid ... definitions ...") — a
/// deploy-passes/runtime-breaks defect.
///
/// <para>
/// These tests assert: a VALID ref resolves and validation proceeds against the
/// referenced shape; a DANGLING ref yields a CLEAR Invalid result naming the
/// missing reference (NOT an opaque "Invalid ... definitions" from a swallowed
/// <see cref="JsonException"/>); and schemas WITHOUT a <c>$ref</c> are unaffected
/// when no resolver is supplied.
/// </para>
/// </summary>
public class SchemaRefRuntimeValidationTests
{
private static JsonElement Body(string json)
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
// ── ParameterValidator: $ref in parameter definitions ─────────────────────
[Fact]
public void ParameterValidator_ValidLibRef_ResolvesAndValidates()
{
// The parameter schema is a single {"$ref":"lib:OrderRequest"} that the
// resolver maps to an object schema with one required integer field.
const string orderRequest =
"""{"type":"object","properties":{"qty":{"type":"integer"}},"required":["qty"]}""";
const string paramDef = """{"$ref":"lib:OrderRequest"}""";
var ok = ParameterValidator.Validate(
Body("""{"qty":5}"""),
paramDef,
resolveRef: name => name == "OrderRequest" ? orderRequest : null);
Assert.True(ok.IsValid, ok.ErrorMessage);
Assert.Equal(5L, ok.Parameters["qty"]);
// And a body that violates the resolved shape is rejected with a real
// type error (NOT a configuration/parse error) — proving the ref shape
// is actually being enforced.
var bad = ParameterValidator.Validate(
Body("""{"qty":"not-a-number"}"""),
paramDef,
resolveRef: name => name == "OrderRequest" ? orderRequest : null);
Assert.False(bad.IsValid);
Assert.DoesNotContain("definitions in method configuration", bad.ErrorMessage);
}
[Fact]
public void ParameterValidator_DanglingLibRef_ReturnsClearInvalidNamingTheRef()
{
// The resolver cannot find "GoneLib" — at runtime this must surface as a
// CLEAR Invalid result that names the missing reference, NOT an opaque
// "Invalid parameter definitions" and NOT a thrown JsonException.
const string paramDef = """{"$ref":"lib:GoneLib"}""";
var result = ParameterValidator.Validate(
Body("""{"anything":1}"""),
paramDef,
resolveRef: _ => null);
Assert.False(result.IsValid);
Assert.NotNull(result.ErrorMessage);
Assert.Contains("GoneLib", result.ErrorMessage!, StringComparison.Ordinal);
}
[Fact]
public void ParameterValidator_NoRef_UnchangedWithoutResolver()
{
// A schema with no $ref behaves exactly as before when no resolver is
// supplied (the default-null resolver path).
const string def =
"""{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}""";
var ok = ParameterValidator.Validate(Body("""{"count":3}"""), def);
Assert.True(ok.IsValid, ok.ErrorMessage);
Assert.Equal(3L, ok.Parameters["count"]);
var bad = ParameterValidator.Validate(Body("""{"count":"x"}"""), def);
Assert.False(bad.IsValid);
}
// ── ReturnValueValidator: $ref in return definition ───────────────────────
[Fact]
public void ReturnValueValidator_ValidLibRef_ResolvesAndValidates()
{
const string report =
"""{"type":"object","properties":{"total":{"type":"integer"}},"required":["total"]}""";
const string returnDef = """{"$ref":"lib:Report"}""";
var ok = ReturnValueValidator.Validate(
"""{"total":42}""",
returnDef,
resolveRef: name => name == "Report" ? report : null);
Assert.True(ok.IsValid, ok.ErrorMessage);
// A return value violating the resolved shape is rejected as a real
// validation failure, not a configuration error.
var bad = ReturnValueValidator.Validate(
"""{"total":"nope"}""",
returnDef,
resolveRef: name => name == "Report" ? report : null);
Assert.False(bad.IsValid);
Assert.DoesNotContain("definition in method configuration", bad.ErrorMessage);
}
[Fact]
public void ReturnValueValidator_DanglingLibRef_ReturnsClearInvalidNamingTheRef()
{
const string returnDef = """{"$ref":"lib:GoneReturn"}""";
var result = ReturnValueValidator.Validate(
"""{"total":1}""",
returnDef,
resolveRef: _ => null);
Assert.False(result.IsValid);
Assert.Contains("GoneReturn", result.ErrorMessage, StringComparison.Ordinal);
}
[Fact]
public void ReturnValueValidator_NoRef_UnchangedWithoutResolver()
{
const string def =
"""{"type":"object","properties":{"total":{"type":"integer"}},"required":["total"]}""";
var ok = ReturnValueValidator.Validate("""{"total":7}""", def);
Assert.True(ok.IsValid, ok.ErrorMessage);
var bad = ReturnValueValidator.Validate("""{"total":"x"}""", def);
Assert.False(bad.IsValid);
}
}