feat(m9/T32b): JSON Schema $ref resolver (lib seam, cycle/depth-guarded) + deploy-time dangling-ref block
This commit is contained in:
@@ -86,5 +86,12 @@ public enum ValidationCategory
|
||||
CrossCallViolation,
|
||||
MissingMetadata,
|
||||
ConnectionConfig,
|
||||
NativeAlarmSourceInvalid
|
||||
NativeAlarmSourceInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// M9-T32b: a script/method parameter or return JSON Schema contains a
|
||||
/// <c>{"$ref":"lib:Name"}</c> reference that could not be resolved against the
|
||||
/// shared-schema library — dangling, cyclic, or over-depth. Deploy-blocking.
|
||||
/// </summary>
|
||||
SchemaReference
|
||||
}
|
||||
|
||||
@@ -60,29 +60,131 @@ public sealed class InboundApiSchema
|
||||
/// <param name="json">The definition JSON; null/whitespace yields <c>null</c>.</param>
|
||||
/// <returns>The parsed schema, or <c>null</c> when the input is empty.</returns>
|
||||
/// <exception cref="JsonException">The input is non-empty but not valid JSON, is a JSON scalar/null at the root, or the schema nesting exceeds <see cref="MaxDepth"/>.</exception>
|
||||
public static InboundApiSchema? Parse(string? json)
|
||||
public static InboundApiSchema? Parse(string? json) => Parse(json, resolveRef: null);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a stored definition string into an <see cref="InboundApiSchema"/>,
|
||||
/// resolving any <c>{"$ref":"lib:Name"}</c> library references (M9-T32b) through
|
||||
/// the caller-supplied <paramref name="resolveRef"/> seam.
|
||||
///
|
||||
/// <para>
|
||||
/// The pointer convention is <c>lib:Name</c>: a JSON object carrying a string
|
||||
/// <c>$ref</c> whose value begins with the <c>lib:</c> scheme prefix is a
|
||||
/// reference to the library entry named by the remainder. The seam maps that
|
||||
/// name (the part after <c>lib:</c>) to the referenced schema JSON, or returns
|
||||
/// <c>null</c> when the entry does not exist. Resolution is recursive (a
|
||||
/// referenced schema may itself contain <c>$ref</c>s) and guarded against
|
||||
/// cycles and excessive depth — a cycle or over-depth chain surfaces as a
|
||||
/// controlled <see cref="JsonException"/>, never a stack overflow.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A <c>$ref</c> the seam cannot resolve (returns <c>null</c>), a <c>$ref</c>
|
||||
/// with no resolver supplied, and a cyclic/over-depth <c>$ref</c> are all
|
||||
/// DANGLING — surfaced here as a <see cref="JsonException"/> so a caller using
|
||||
/// the throwing path treats them as a hard error. (For deploy-time validation
|
||||
/// that needs to COLLECT dangling refs rather than throw, use
|
||||
/// <see cref="ParseWithRefs"/>.)
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Schemas with no <c>$ref</c> parse identically to <see cref="Parse(string?)"/>;
|
||||
/// the resolver is never consulted for them, so behavior is unchanged.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="json">The definition JSON; null/whitespace yields <c>null</c>.</param>
|
||||
/// <param name="resolveRef">
|
||||
/// Optional reference-resolution seam mapping a <c>lib:Name</c> target's name to
|
||||
/// the referenced schema JSON (or <c>null</c> when not found). <c>null</c> means
|
||||
/// no resolver: a <c>$ref</c> then dangles. The seam keeps this Commons type free
|
||||
/// of any repository dependency — the caller (the validation layer) supplies it.
|
||||
/// </param>
|
||||
/// <returns>The parsed (and ref-resolved) schema, or <c>null</c> when the input is empty.</returns>
|
||||
/// <exception cref="JsonException">The input is invalid JSON, a root scalar/null, exceeds <see cref="MaxDepth"/>, or contains a dangling/cyclic/over-depth <c>$ref</c>.</exception>
|
||||
public static InboundApiSchema? Parse(string? json, Func<string, string?>? resolveRef)
|
||||
{
|
||||
var result = ParseWithRefs(json, resolveRef);
|
||||
if (result.UnresolvedRefs.Count > 0)
|
||||
{
|
||||
throw new JsonException(
|
||||
$"Schema contains unresolved $ref(s): {string.Join(", ", result.UnresolvedRefs)}.");
|
||||
}
|
||||
|
||||
return result.Schema;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a stored definition string into an <see cref="InboundApiSchema"/>,
|
||||
/// resolving <c>{"$ref":"lib:Name"}</c> library references through the
|
||||
/// caller-supplied <paramref name="resolveRef"/> seam, and COLLECTING (rather than
|
||||
/// throwing on) any references that cannot be resolved (M9-T32b).
|
||||
///
|
||||
/// <para>
|
||||
/// This is the deploy-time entry point: a dangling, cyclic, or over-depth
|
||||
/// <c>$ref</c> is reported in <see cref="SchemaParseResult.UnresolvedRefs"/> so the
|
||||
/// validation layer can surface it as a deploy-blocking error naming the missing
|
||||
/// reference, instead of aborting the whole parse. See <see cref="Parse(string?, Func{string, string?})"/>
|
||||
/// for the throwing variant and for the <c>lib:Name</c> pointer convention.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="json">The definition JSON; null/whitespace yields a result with a <c>null</c> schema and no unresolved refs.</param>
|
||||
/// <param name="resolveRef">
|
||||
/// Optional reference-resolution seam (see <see cref="Parse(string?, Func{string, string?})"/>).
|
||||
/// <c>null</c> means no resolver: any <c>$ref</c> is reported as unresolved.
|
||||
/// </param>
|
||||
/// <returns>The parsed schema (refs resolved where possible) plus the names of any references that could not be resolved.</returns>
|
||||
/// <exception cref="JsonException">The input is invalid JSON, a root scalar/null, or exceeds the structural <see cref="MaxDepth"/> for non-ref nesting.</exception>
|
||||
public static SchemaParseResult ParseWithRefs(string? json, Func<string, string?>? resolveRef)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
return new SchemaParseResult(null, []);
|
||||
}
|
||||
|
||||
var unresolved = new List<string>();
|
||||
// The active-ref set tracks the refs being resolved on the CURRENT path so a
|
||||
// cycle (A→B→A) is detected and reported instead of recursing forever.
|
||||
var ctx = new RefResolutionContext(resolveRef, unresolved, new HashSet<string>(StringComparer.Ordinal));
|
||||
|
||||
using var doc = JsonDocument.Parse(json, DocOptions);
|
||||
return doc.RootElement.ValueKind switch
|
||||
var schema = doc.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => ParseSchema(doc.RootElement, depth: 0),
|
||||
JsonValueKind.Object => ParseSchema(doc.RootElement, depth: 0, ctx),
|
||||
JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
|
||||
_ => throw new JsonException("Type definition must be a JSON object (JSON Schema) or legacy parameter array."),
|
||||
};
|
||||
|
||||
return new SchemaParseResult(schema, unresolved);
|
||||
}
|
||||
|
||||
private static InboundApiSchema ParseSchema(JsonElement el, int depth)
|
||||
/// <summary>
|
||||
/// Carries the <c>$ref</c> resolution state threaded through the recursive parse:
|
||||
/// the caller-supplied resolver seam, the accumulator for unresolved references,
|
||||
/// and the set of references active on the current resolution path (the cycle
|
||||
/// guard). A <c>null</c> <see cref="Resolver"/> means no resolver was supplied —
|
||||
/// every <c>$ref</c> then dangles.
|
||||
/// </summary>
|
||||
private sealed record RefResolutionContext(
|
||||
Func<string, string?>? Resolver,
|
||||
List<string> Unresolved,
|
||||
HashSet<string> ActiveRefs);
|
||||
|
||||
private static InboundApiSchema ParseSchema(JsonElement el, int depth, RefResolutionContext ctx)
|
||||
{
|
||||
if (depth > MaxDepth)
|
||||
{
|
||||
throw new JsonException($"Schema nesting exceeds the maximum allowed depth of {MaxDepth}.");
|
||||
}
|
||||
|
||||
// $ref resolution (M9-T32b): a {"$ref":"lib:Name"} node is replaced by the
|
||||
// referenced schema, resolved through the caller-supplied seam. Dangling,
|
||||
// cyclic, and over-depth refs are recorded as unresolved (the caller decides
|
||||
// whether to throw or collect) and parse continues with a shape-only schema.
|
||||
if (TryReadLibRef(el, out var refName))
|
||||
{
|
||||
return ResolveLibRef(refName, depth, ctx);
|
||||
}
|
||||
|
||||
var type = el.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? NormalizeType(t.GetString())
|
||||
: "string";
|
||||
@@ -92,7 +194,7 @@ public sealed class InboundApiSchema
|
||||
InboundApiSchema? items = null;
|
||||
if (el.TryGetProperty("items", out var itemsEl) && itemsEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
items = ParseSchema(itemsEl, depth + 1);
|
||||
items = ParseSchema(itemsEl, depth + 1, ctx);
|
||||
}
|
||||
|
||||
return new InboundApiSchema { Type = "array", Items = items };
|
||||
@@ -122,7 +224,7 @@ public sealed class InboundApiSchema
|
||||
foreach (var prop in props.EnumerateObject())
|
||||
{
|
||||
var schema = prop.Value.ValueKind == JsonValueKind.Object
|
||||
? ParseSchema(prop.Value, depth + 1)
|
||||
? ParseSchema(prop.Value, depth + 1, ctx)
|
||||
: new InboundApiSchema { Type = "string" };
|
||||
fields.Add(new InboundApiSchemaField(prop.Name, requiredSet.Contains(prop.Name), schema));
|
||||
}
|
||||
@@ -134,6 +236,103 @@ public sealed class InboundApiSchema
|
||||
return new InboundApiSchema { Type = type };
|
||||
}
|
||||
|
||||
/// <summary>The <c>lib:</c> scheme prefix on a <c>$ref</c> value identifying a library reference.</summary>
|
||||
private const string LibRefScheme = "lib:";
|
||||
|
||||
/// <summary>The placeholder type for an unresolvable <c>$ref</c> node (dangling, cyclic, or over-depth).</summary>
|
||||
private const string UnresolvedRefType = "ref";
|
||||
|
||||
/// <summary>
|
||||
/// Recognizes a <c>{"$ref":"lib:Name"}</c> reference node and extracts its target
|
||||
/// name (the part after the <c>lib:</c> scheme prefix). Returns <c>false</c> for any
|
||||
/// node that is not a <c>lib:</c> reference, so non-ref schemas take the normal path.
|
||||
/// </summary>
|
||||
/// <param name="el">The schema node to inspect.</param>
|
||||
/// <param name="refName">The resolved target name (after <c>lib:</c>) when this is a <c>lib:</c> ref; otherwise empty.</param>
|
||||
/// <returns><c>true</c> when <paramref name="el"/> is a <c>lib:</c> <c>$ref</c> node with a non-empty target name.</returns>
|
||||
private static bool TryReadLibRef(JsonElement el, out string refName)
|
||||
{
|
||||
refName = string.Empty;
|
||||
if (!el.TryGetProperty("$ref", out var refEl) || refEl.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var raw = refEl.GetString();
|
||||
if (string.IsNullOrEmpty(raw) || !raw.StartsWith(LibRefScheme, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var name = raw[LibRefScheme.Length..].Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
refName = name;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a <c>lib:Name</c> reference through the seam, parsing the referenced
|
||||
/// schema (which may itself contain <c>$ref</c>s). Dangling (seam returns null or no
|
||||
/// seam), cyclic (the name is already active on the current path), and over-depth
|
||||
/// references are recorded in the context's unresolved list and yield a shape-only
|
||||
/// placeholder schema so the parse terminates without throwing or overflowing.
|
||||
/// </summary>
|
||||
/// <param name="refName">The library entry name to resolve.</param>
|
||||
/// <param name="depth">The current structural depth (shared with the <see cref="MaxDepth"/> guard).</param>
|
||||
/// <param name="ctx">The active resolution context (seam, unresolved accumulator, cycle guard).</param>
|
||||
/// <returns>The resolved schema, or a placeholder <c>ref</c>-typed schema when unresolvable.</returns>
|
||||
private static InboundApiSchema ResolveLibRef(string refName, int depth, RefResolutionContext ctx)
|
||||
{
|
||||
// Depth guard: a long (even non-cyclic) ref chain is bounded by the same
|
||||
// structural ceiling as nested objects/arrays — terminate, never overflow.
|
||||
// The guard fires at `>= MaxDepth` (one level BEFORE ParseSchema's own
|
||||
// `> MaxDepth` throw) so an over-depth ref is COLLECTED as unresolved on the
|
||||
// ParseWithRefs path rather than aborting the whole parse with a throw.
|
||||
if (depth >= MaxDepth)
|
||||
{
|
||||
ctx.Unresolved.Add($"{refName} (ref nesting exceeds depth {MaxDepth})");
|
||||
return new InboundApiSchema { Type = UnresolvedRefType };
|
||||
}
|
||||
|
||||
// Cycle guard: this name is already being resolved on the current path.
|
||||
if (ctx.ActiveRefs.Contains(refName))
|
||||
{
|
||||
ctx.Unresolved.Add($"{refName} (cyclic reference)");
|
||||
return new InboundApiSchema { Type = UnresolvedRefType };
|
||||
}
|
||||
|
||||
var referenced = ctx.Resolver?.Invoke(refName);
|
||||
if (string.IsNullOrWhiteSpace(referenced))
|
||||
{
|
||||
// Dangling: the seam can't resolve it (or no seam was supplied).
|
||||
ctx.Unresolved.Add(refName);
|
||||
return new InboundApiSchema { Type = UnresolvedRefType };
|
||||
}
|
||||
|
||||
ctx.ActiveRefs.Add(refName);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(referenced, DocOptions);
|
||||
return doc.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => ParseSchema(doc.RootElement, depth + 1, ctx),
|
||||
JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
|
||||
_ => throw new JsonException(
|
||||
$"Referenced schema 'lib:{refName}' must be a JSON object (JSON Schema) or legacy parameter array."),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Pop only AFTER the subtree is resolved so sibling refs to the same name
|
||||
// are allowed (a diamond is not a cycle) while a self-revisit on the path is caught.
|
||||
ctx.ActiveRefs.Remove(refName);
|
||||
}
|
||||
}
|
||||
|
||||
private static InboundApiSchema ParseLegacyArray(JsonElement arr)
|
||||
{
|
||||
var fields = new List<InboundApiSchemaField>();
|
||||
@@ -391,3 +590,20 @@ public sealed class InboundApiSchema
|
||||
/// <param name="Required">Whether the field must be present.</param>
|
||||
/// <param name="Schema">The recursive type schema the field's value must satisfy.</param>
|
||||
public sealed record InboundApiSchemaField(string Name, bool Required, InboundApiSchema Schema);
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of <see cref="InboundApiSchema.ParseWithRefs"/> (M9-T32b): the parsed
|
||||
/// schema (with <c>{"$ref":"lib:Name"}</c> references resolved where possible) plus the
|
||||
/// names of any references that could NOT be resolved — dangling (the seam returned
|
||||
/// <c>null</c> or no seam was supplied), cyclic, or over-depth. A non-empty
|
||||
/// <see cref="UnresolvedRefs"/> is the deploy-blocking signal the validation layer acts on.
|
||||
/// </summary>
|
||||
/// <param name="Schema">The parsed schema, or <c>null</c> when the input was empty.</param>
|
||||
/// <param name="UnresolvedRefs">
|
||||
/// The reference targets that could not be resolved, each annotated with the reason for
|
||||
/// cyclic/over-depth cases (e.g. <c>"Foo (cyclic reference)"</c>). Empty when every
|
||||
/// reference resolved.
|
||||
/// </param>
|
||||
public sealed record SchemaParseResult(
|
||||
InboundApiSchema? Schema,
|
||||
IReadOnlyList<string> UnresolvedRefs);
|
||||
|
||||
@@ -22,6 +22,7 @@ public class FlatteningPipeline : IFlatteningPipeline
|
||||
private readonly FlatteningService _flatteningService;
|
||||
private readonly ValidationService _validationService;
|
||||
private readonly RevisionHashService _revisionHashService;
|
||||
private readonly ISharedSchemaRepository _sharedSchemaRepo;
|
||||
|
||||
/// <summary>Initializes a new <see cref="FlatteningPipeline"/> with the required template engine and site repositories and services.</summary>
|
||||
/// <param name="templateRepo">Repository for loading templates and instance data.</param>
|
||||
@@ -29,18 +30,25 @@ public class FlatteningPipeline : IFlatteningPipeline
|
||||
/// <param name="flatteningService">Service that flattens the template inheritance chain into a resolved config.</param>
|
||||
/// <param name="validationService">Service that performs semantic validation on the flattened config.</param>
|
||||
/// <param name="revisionHashService">Service that computes the revision hash for staleness detection.</param>
|
||||
/// <param name="sharedSchemaRepo">
|
||||
/// M9-T32b: repository backing the JSON-Schema <c>$ref</c> resolution seam. Used to
|
||||
/// look up <c>lib:Name</c> library references so a dangling reference in any validated
|
||||
/// script schema becomes a deploy-blocking error.
|
||||
/// </param>
|
||||
public FlatteningPipeline(
|
||||
ITemplateEngineRepository templateRepo,
|
||||
ISiteRepository siteRepo,
|
||||
FlatteningService flatteningService,
|
||||
ValidationService validationService,
|
||||
RevisionHashService revisionHashService)
|
||||
RevisionHashService revisionHashService,
|
||||
ISharedSchemaRepository sharedSchemaRepo)
|
||||
{
|
||||
_templateRepo = templateRepo;
|
||||
_siteRepo = siteRepo;
|
||||
_flatteningService = flatteningService;
|
||||
_validationService = validationService;
|
||||
_revisionHashService = revisionHashService;
|
||||
_sharedSchemaRepo = sharedSchemaRepo;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -135,6 +143,15 @@ public class FlatteningPipeline : IFlatteningPipeline
|
||||
.Select(c => c.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// M9-T32b: build the JSON-Schema $ref resolution seam from the shared-schema
|
||||
// library. The seam ValidationService consumes is synchronous, so the library is
|
||||
// pre-loaded once into a name→JSON map here (avoiding sync-over-async) and the
|
||||
// seam is a pure in-memory lookup. An unresolved {"$ref":"lib:Name"} in any
|
||||
// validated script schema then becomes a deploy-blocking SchemaReference error.
|
||||
var sharedSchemas = await _sharedSchemaRepo.ListAsync(cancellationToken);
|
||||
var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
|
||||
Func<string, string?> resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);
|
||||
|
||||
// Validate. This is the deploy-gating path, so connection-binding completeness
|
||||
// is enforced as an Error (enforceConnectionBindings: true): a data-sourced
|
||||
// attribute with no binding — or one bound to a connection that no longer exists
|
||||
@@ -146,7 +163,8 @@ public class FlatteningPipeline : IFlatteningPipeline
|
||||
resolvedSharedScripts,
|
||||
alarmCapableConnectionNames,
|
||||
enforceConnectionBindings: true,
|
||||
siteConnectionNames: siteConnectionNames);
|
||||
siteConnectionNames: siteConnectionNames,
|
||||
resolveSchemaRef: resolveSchemaRef);
|
||||
|
||||
// Compute revision hash
|
||||
var hash = _revisionHashService.ComputeHash(config);
|
||||
|
||||
@@ -571,9 +571,18 @@ public class ManagementActor : ReceiveActor
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// M9-T32b: supply the JSON-Schema $ref resolution seam from the shared-schema
|
||||
// library so a dangling {"$ref":"lib:Name"} in a template script schema is flagged
|
||||
// here (design-time validate) consistently with the deploy path. The library is
|
||||
// pre-loaded into a name→JSON map (the seam ValidationService consumes is sync).
|
||||
var sharedSchemaRepo = sp.GetRequiredService<ISharedSchemaRepository>();
|
||||
var sharedSchemas = await sharedSchemaRepo.ListAsync();
|
||||
var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
|
||||
Func<string, string?> resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);
|
||||
|
||||
// Run full validation pipeline (collisions, script compilation, trigger refs, bindings)
|
||||
var validationService = new TemplateEngine.Validation.ValidationService();
|
||||
var validationResult = validationService.Validate(flatConfig);
|
||||
var validationResult = validationService.Validate(flatConfig, resolveSchemaRef: resolveSchemaRef);
|
||||
|
||||
// Also detect naming collisions across the inheritance/composition graph
|
||||
var svc = sp.GetRequiredService<TemplateService>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
@@ -83,13 +84,25 @@ public class ValidationService
|
||||
/// connection is checked against this set so a binding to a phantom/stale connection
|
||||
/// is caught. <c>null</c> skips the "exists at site" half (it stays inert).
|
||||
/// </param>
|
||||
/// <param name="resolveSchemaRef">
|
||||
/// M9-T32b: optional JSON-Schema <c>$ref</c> resolution seam mapping a
|
||||
/// <c>lib:Name</c> reference target's name to the referenced schema JSON (or
|
||||
/// <c>null</c> when the library entry does not exist). Supplied on the deploy path
|
||||
/// (backed by <c>ISharedSchemaRepository</c>) so a dangling/cyclic/over-depth
|
||||
/// <c>$ref</c> in any validated script parameter/return schema becomes a
|
||||
/// deploy-blocking <see cref="ValidationCategory.SchemaReference"/> error naming the
|
||||
/// missing reference. When <c>null</c> the seam is absent: schemas with no
|
||||
/// <c>$ref</c> are unaffected (behavior unchanged), and a <c>$ref</c> with no
|
||||
/// resolver is treated as dangling (the safe option).
|
||||
/// </param>
|
||||
/// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
|
||||
public ValidationResult Validate(
|
||||
FlattenedConfiguration configuration,
|
||||
IReadOnlyList<ResolvedScript>? sharedScripts = null,
|
||||
IReadOnlySet<string>? alarmCapableConnectionNames = null,
|
||||
bool enforceConnectionBindings = false,
|
||||
IReadOnlySet<string>? siteConnectionNames = null)
|
||||
IReadOnlySet<string>? siteConnectionNames = null,
|
||||
Func<string, string?>? resolveSchemaRef = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
@@ -102,12 +115,86 @@ public class ValidationService
|
||||
ValidateScriptTriggerReferences(configuration),
|
||||
ValidateExpressionTriggers(configuration),
|
||||
ValidateConnectionBindingCompleteness(configuration, enforceConnectionBindings, siteConnectionNames),
|
||||
ValidateSchemaReferences(configuration, sharedScripts, resolveSchemaRef),
|
||||
_semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames)
|
||||
};
|
||||
|
||||
return ValidationResult.Merge(results.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M9-T32b — JSON-Schema <c>$ref</c> resolution check. Parses every script
|
||||
/// parameter/return schema (instance scripts and the supplied shared scripts)
|
||||
/// through <see cref="InboundApiSchema.ParseWithRefs"/>, resolving any
|
||||
/// <c>{"$ref":"lib:Name"}</c> reference via <paramref name="resolveSchemaRef"/>.
|
||||
/// A reference that cannot be resolved — dangling (the seam returns <c>null</c> or
|
||||
/// none is supplied), cyclic, or over-depth — is a deploy-blocking
|
||||
/// <see cref="ValidationCategory.SchemaReference"/> error naming the missing
|
||||
/// reference and the owning script.
|
||||
///
|
||||
/// <para>
|
||||
/// The check is INERT for schemas that contain no <c>$ref</c>:
|
||||
/// <see cref="InboundApiSchema.ParseWithRefs"/> never consults the seam for them and
|
||||
/// reports no unresolved refs, so the pre-existing validation behavior is unchanged
|
||||
/// (this is the only edit to the schema validation path for non-<c>$ref</c> schemas).
|
||||
/// A malformed (non-JSON) schema is left to the existing script-compilation /
|
||||
/// semantic checks — this check swallows the parse exception and reports nothing,
|
||||
/// so it never double-reports a structural problem as a missing reference.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration whose scripts' schemas are checked.</param>
|
||||
/// <param name="sharedScripts">Optional shared scripts whose schemas are also checked.</param>
|
||||
/// <param name="resolveSchemaRef">The <c>$ref</c> resolution seam, or <c>null</c> (no resolver → refs dangle).</param>
|
||||
/// <returns>A <see cref="ValidationResult"/> with one error per unresolved reference, or success.</returns>
|
||||
internal static ValidationResult ValidateSchemaReferences(
|
||||
FlattenedConfiguration configuration,
|
||||
IReadOnlyList<ResolvedScript>? sharedScripts,
|
||||
Func<string, string?>? resolveSchemaRef)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
|
||||
foreach (var script in configuration.Scripts)
|
||||
{
|
||||
CheckSchema(script.CanonicalName, "parameter definitions", script.ParameterDefinitions);
|
||||
CheckSchema(script.CanonicalName, "return definition", script.ReturnDefinition);
|
||||
}
|
||||
|
||||
foreach (var shared in sharedScripts ?? [])
|
||||
{
|
||||
CheckSchema(shared.CanonicalName, "parameter definitions", shared.ParameterDefinitions);
|
||||
CheckSchema(shared.CanonicalName, "return definition", shared.ReturnDefinition);
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? new ValidationResult { Errors = errors }
|
||||
: ValidationResult.Success();
|
||||
|
||||
void CheckSchema(string scriptName, string schemaLabel, string? schemaJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schemaJson))
|
||||
return;
|
||||
|
||||
SchemaParseResult parsed;
|
||||
try
|
||||
{
|
||||
parsed = InboundApiSchema.ParseWithRefs(schemaJson, resolveSchemaRef);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed schema JSON / over-depth nesting is surfaced by the other
|
||||
// validation stages — not a missing-reference concern. Don't double-report.
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var missing in parsed.UnresolvedRefs)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.SchemaReference,
|
||||
$"Script '{scriptName}' {schemaLabel} references schema 'lib:{missing}' which could not be resolved.",
|
||||
scriptName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that flattening produced a non-empty configuration.
|
||||
/// </summary>
|
||||
|
||||
+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