feat(m9/T32b): JSON Schema $ref resolver (lib seam, cycle/depth-guarded) + deploy-time dangling-ref block
This commit is contained in:
+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