feat(m9/T32b): JSON Schema $ref resolver (lib seam, cycle/depth-guarded) + deploy-time dangling-ref block
This commit is contained in:
+222
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user