feat(m9/T32b): JSON Schema $ref resolver (lib seam, cycle/depth-guarded) + deploy-time dangling-ref block

This commit is contained in:
Joseph Doherty
2026-06-18 11:54:19 -04:00
parent 16cb078cd2
commit b3d99248fa
9 changed files with 755 additions and 14 deletions
@@ -0,0 +1,222 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.InboundApi;
/// <summary>
/// Tests for the M9-T32b JSON-Schema <c>$ref</c> resolver: the caller-supplied
/// resolution seam (<c>lib:Name</c> pointer convention), cycle/depth guarding,
/// and dangling-ref reporting. The resolver is OPTIONAL — schemas without a
/// <c>$ref</c> parse exactly as before (verified by the no-ref-unchanged tests).
/// </summary>
public class InboundApiSchemaRefTests
{
// ── Pointer convention: {"$ref":"lib:Name"} resolves through the seam ──────
[Fact]
public void ParseWithRefs_LibRef_ResolvesToReferencedShape()
{
// "Foo" is an object schema with a single required integer field.
const string fooSchema = """{"type":"object","properties":{"qty":{"type":"integer"}},"required":["qty"]}""";
const string json = """{"$ref":"lib:Foo"}""";
var result = InboundApiSchema.ParseWithRefs(
json,
name => name == "Foo" ? fooSchema : null);
Assert.Empty(result.UnresolvedRefs);
Assert.NotNull(result.Schema);
Assert.Equal("object", result.Schema!.Type);
var field = Assert.Single(result.Schema.Fields);
Assert.Equal("qty", field.Name);
Assert.True(field.Required);
Assert.Equal("integer", field.Schema.Type);
}
[Fact]
public void ParseWithRefs_NestedFieldRef_ResolvesInline()
{
// A $ref nested inside a property is resolved to the referenced schema.
const string addressSchema = """{"type":"object","properties":{"zip":{"type":"string"}}}""";
const string json = """{"type":"object","properties":{"shipTo":{"$ref":"lib:Address"}}}""";
var result = InboundApiSchema.ParseWithRefs(
json,
name => name == "Address" ? addressSchema : null);
Assert.Empty(result.UnresolvedRefs);
Assert.NotNull(result.Schema);
var shipTo = Assert.Single(result.Schema!.Fields);
Assert.Equal("shipTo", shipTo.Name);
Assert.Equal("object", shipTo.Schema.Type);
Assert.Equal("zip", Assert.Single(shipTo.Schema.Fields).Name);
}
[Fact]
public void Parse_LibRef_WithResolver_ResolvesToReferencedShape()
{
// The throwing Parse overload also resolves a valid ref.
const string fooSchema = """{"type":"object","properties":{"name":{"type":"string"}}}""";
var schema = InboundApiSchema.Parse(
"""{"$ref":"lib:Foo"}""",
name => name == "Foo" ? fooSchema : null);
Assert.NotNull(schema);
Assert.Equal("object", schema!.Type);
Assert.Equal("name", Assert.Single(schema.Fields).Name);
}
// ── Dangling ref: resolver returns null → reported, not silently dropped ──
[Fact]
public void ParseWithRefs_DanglingRef_IsReported()
{
const string json = """{"$ref":"lib:Missing"}""";
var result = InboundApiSchema.ParseWithRefs(json, _ => null);
Assert.Contains("Missing", result.UnresolvedRefs);
}
[Fact]
public void ParseWithRefs_DanglingNestedRef_IsReported()
{
const string json = """{"type":"object","properties":{"a":{"$ref":"lib:Gone"}}}""";
var result = InboundApiSchema.ParseWithRefs(json, _ => null);
Assert.Contains("Gone", result.UnresolvedRefs);
}
[Fact]
public void ParseWithRefs_RefWithNoResolver_IsDangling()
{
// When no resolver is supplied, a $ref cannot be resolved → dangling
// (the safe option: surfaced, never silently treated as a valid shape).
const string json = """{"$ref":"lib:Whatever"}""";
var result = InboundApiSchema.ParseWithRefs(json, resolveRef: null);
Assert.Contains("Whatever", result.UnresolvedRefs);
}
[Fact]
public void Parse_DanglingRef_WithResolver_Throws()
{
// The throwing Parse overload surfaces a dangling ref as a JsonException
// (consistent with its existing throw-on-structural-error contract).
Assert.Throws<JsonException>(() =>
InboundApiSchema.Parse("""{"$ref":"lib:Nope"}""", _ => null));
}
// ── Cycle / depth guard: controlled error, no stack overflow ──────────────
[Fact]
public void ParseWithRefs_DirectCycle_IsReportedNotStackOverflow()
{
// Foo -> Foo. Must terminate with a controlled cycle error.
var schemas = new Dictionary<string, string>
{
["Foo"] = """{"$ref":"lib:Foo"}""",
};
var result = InboundApiSchema.ParseWithRefs(
"""{"$ref":"lib:Foo"}""",
name => schemas.GetValueOrDefault(name));
Assert.NotEmpty(result.UnresolvedRefs);
Assert.Contains(result.UnresolvedRefs, r => r.Contains("Foo", StringComparison.Ordinal));
}
[Fact]
public void ParseWithRefs_IndirectCycle_IsReportedNotStackOverflow()
{
// Foo -> Bar -> Foo. Must terminate with a controlled cycle error.
var schemas = new Dictionary<string, string>
{
["Foo"] = """{"$ref":"lib:Bar"}""",
["Bar"] = """{"$ref":"lib:Foo"}""",
};
var result = InboundApiSchema.ParseWithRefs(
"""{"$ref":"lib:Foo"}""",
name => schemas.GetValueOrDefault(name));
Assert.NotEmpty(result.UnresolvedRefs);
}
[Fact]
public void Parse_DirectCycle_WithResolver_ThrowsNotStackOverflow()
{
var schemas = new Dictionary<string, string>
{
["Foo"] = """{"$ref":"lib:Foo"}""",
};
Assert.Throws<JsonException>(() =>
InboundApiSchema.Parse(
"""{"$ref":"lib:Foo"}""",
name => schemas.GetValueOrDefault(name)));
}
[Fact]
public void ParseWithRefs_DeepRefChain_RespectsDepthCapWithoutOverflow()
{
// Build a long non-cyclic chain that exceeds the structural depth cap.
// The guard must terminate with a controlled error, never overflow.
var schemas = new Dictionary<string, string>();
for (var i = 0; i < 200; i++)
{
schemas[$"S{i}"] = $$"""{"$ref":"lib:S{{i + 1}}"}""";
}
// The final ref dangles, but the depth guard should fire well before that.
var result = InboundApiSchema.ParseWithRefs(
"""{"$ref":"lib:S0"}""",
name => schemas.GetValueOrDefault(name));
Assert.NotEmpty(result.UnresolvedRefs);
}
// ── Behavior unchanged for schemas WITHOUT a $ref ─────────────────────────
[Fact]
public void ParseWithRefs_NoRef_MatchesParse()
{
const string json = """{"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"string"}},"required":["a"]}""";
var legacy = InboundApiSchema.Parse(json);
var withRefs = InboundApiSchema.ParseWithRefs(json, _ => null);
Assert.Empty(withRefs.UnresolvedRefs);
Assert.NotNull(withRefs.Schema);
Assert.Equal(legacy!.Type, withRefs.Schema!.Type);
Assert.Equal(legacy.Fields.Count, withRefs.Schema.Fields.Count);
Assert.Equal("a", withRefs.Schema.Fields[0].Name);
Assert.True(withRefs.Schema.Fields[0].Required);
Assert.False(withRefs.Schema.Fields[1].Required);
}
[Fact]
public void Parse_NoRef_UnchangedWithOrWithoutResolver()
{
const string json = """{"type":"array","items":{"type":"number"}}""";
var noResolver = InboundApiSchema.Parse(json);
var withResolver = InboundApiSchema.Parse(json, _ => "unused");
Assert.NotNull(noResolver);
Assert.NotNull(withResolver);
Assert.Equal("array", noResolver!.Type);
Assert.Equal("array", withResolver!.Type);
Assert.Equal("number", noResolver.Items!.Type);
Assert.Equal("number", withResolver.Items!.Type);
}
[Fact]
public void ParseWithRefs_Empty_ReturnsNullSchemaNoRefs()
{
var result = InboundApiSchema.ParseWithRefs(null, _ => "x");
Assert.Null(result.Schema);
Assert.Empty(result.UnresolvedRefs);
}
}