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);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -26,16 +26,21 @@ public class FlatteningPipelineConnectionBindingTests
|
||||
|
||||
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
|
||||
private readonly FlatteningPipeline _sut;
|
||||
|
||||
public FlatteningPipelineConnectionBindingTests()
|
||||
{
|
||||
_sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
_sut = new FlatteningPipeline(
|
||||
_templateRepo,
|
||||
_siteRepo,
|
||||
new FlatteningService(),
|
||||
new ValidationService(),
|
||||
new RevisionHashService());
|
||||
new RevisionHashService(),
|
||||
_sharedSchemaRepo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+6
-1
@@ -26,16 +26,21 @@ public class FlatteningPipelineNativeAlarmCapabilityTests
|
||||
|
||||
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
|
||||
private readonly FlatteningPipeline _sut;
|
||||
|
||||
public FlatteningPipelineNativeAlarmCapabilityTests()
|
||||
{
|
||||
_sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
_sut = new FlatteningPipeline(
|
||||
_templateRepo,
|
||||
_siteRepo,
|
||||
new FlatteningService(),
|
||||
new ValidationService(),
|
||||
new RevisionHashService());
|
||||
new RevisionHashService(),
|
||||
_sharedSchemaRepo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// M9-T32b deploy-time validation: a dangling <c>{"$ref":"lib:Name"}</c> in any
|
||||
/// validated script parameter/return schema must BLOCK deployment (a
|
||||
/// <see cref="ValidationCategory.SchemaReference"/> error naming the missing ref).
|
||||
/// A valid ref resolved through the supplied seam passes. The check is inert for
|
||||
/// schemas with no <c>$ref</c> (existing behaviour unchanged) and when no resolver
|
||||
/// is supplied and there are no refs.
|
||||
/// </summary>
|
||||
public class SchemaRefValidationTests
|
||||
{
|
||||
private readonly ValidationService _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_DanglingRefInParameterDefinitions_BlocksDeploy()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "CallMe",
|
||||
Code = "var x = 1;",
|
||||
ParameterDefinitions = """{"$ref":"lib:MissingLib"}""",
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Resolver knows nothing → the ref dangles.
|
||||
var result = _sut.Validate(config, resolveSchemaRef: _ => null);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
var error = Assert.Single(result.Errors, e => e.Category == ValidationCategory.SchemaReference);
|
||||
Assert.Contains("MissingLib", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DanglingRefInReturnDefinition_BlocksDeploy()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "CallMe",
|
||||
Code = "var x = 1;",
|
||||
ReturnDefinition = """{"$ref":"lib:GoneReturn"}""",
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config, resolveSchemaRef: _ => null);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Category == ValidationCategory.SchemaReference
|
||||
&& e.Message.Contains("GoneReturn", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRef_Passes()
|
||||
{
|
||||
const string libSchema = """{"type":"object","properties":{"qty":{"type":"integer"}}}""";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "CallMe",
|
||||
Code = "var x = 1;",
|
||||
TriggerType = "Call",
|
||||
ParameterDefinitions = """{"$ref":"lib:OrderRequest"}""",
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(
|
||||
config,
|
||||
resolveSchemaRef: name => name == "OrderRequest" ? libSchema : null);
|
||||
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.SchemaReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NoRefSchema_NoSchemaReferenceError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "CallMe",
|
||||
Code = "var x = 1;",
|
||||
ParameterDefinitions = """{"type":"object","properties":{"a":{"type":"integer"}}}""",
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Even with a resolver present, a schema with no $ref yields no ref errors.
|
||||
var result = _sut.Validate(config, resolveSchemaRef: _ => null);
|
||||
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.SchemaReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NoResolverSupplied_NoRegression()
|
||||
{
|
||||
// The default deploy/design path with no resolver and no $ref schemas must
|
||||
// behave exactly as before (no SchemaReference errors).
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }],
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "Monitor",
|
||||
Code = "var x = 1;",
|
||||
ParameterDefinitions = """{"type":"object","properties":{"a":{"type":"integer"}}}""",
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.SchemaReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CyclicRef_BlocksDeployWithoutOverflow()
|
||||
{
|
||||
var schemas = new Dictionary<string, string>
|
||||
{
|
||||
["A"] = """{"$ref":"lib:B"}""",
|
||||
["B"] = """{"$ref":"lib:A"}""",
|
||||
};
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "CallMe",
|
||||
Code = "var x = 1;",
|
||||
ParameterDefinitions = """{"$ref":"lib:A"}""",
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(
|
||||
config,
|
||||
resolveSchemaRef: name => schemas.GetValueOrDefault(name));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.SchemaReference);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user