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,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);
}
}