diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/ValidationResult.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/ValidationResult.cs
index 4210b6b5..10cb5c31 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/ValidationResult.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/ValidationResult.cs
@@ -86,5 +86,12 @@ public enum ValidationCategory
CrossCallViolation,
MissingMetadata,
ConnectionConfig,
- NativeAlarmSourceInvalid
+ NativeAlarmSourceInvalid,
+
+ ///
+ /// M9-T32b: a script/method parameter or return JSON Schema contains a
+ /// {"$ref":"lib:Name"} reference that could not be resolved against the
+ /// shared-schema library — dangling, cyclic, or over-depth. Deploy-blocking.
+ ///
+ SchemaReference
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs
index 5ee21547..f9e40995 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs
@@ -60,29 +60,131 @@ public sealed class InboundApiSchema
/// The definition JSON; null/whitespace yields null.
/// The parsed schema, or null when the input is empty.
/// The input is non-empty but not valid JSON, is a JSON scalar/null at the root, or the schema nesting exceeds .
- public static InboundApiSchema? Parse(string? json)
+ public static InboundApiSchema? Parse(string? json) => Parse(json, resolveRef: null);
+
+ ///
+ /// Parses a stored definition string into an ,
+ /// resolving any {"$ref":"lib:Name"} library references (M9-T32b) through
+ /// the caller-supplied seam.
+ ///
+ ///
+ /// The pointer convention is lib:Name: a JSON object carrying a string
+ /// $ref whose value begins with the lib: scheme prefix is a
+ /// reference to the library entry named by the remainder. The seam maps that
+ /// name (the part after lib:) to the referenced schema JSON, or returns
+ /// null when the entry does not exist. Resolution is recursive (a
+ /// referenced schema may itself contain $refs) and guarded against
+ /// cycles and excessive depth — a cycle or over-depth chain surfaces as a
+ /// controlled , never a stack overflow.
+ ///
+ ///
+ ///
+ /// A $ref the seam cannot resolve (returns null), a $ref
+ /// with no resolver supplied, and a cyclic/over-depth $ref are all
+ /// DANGLING — surfaced here as a 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
+ /// .)
+ ///
+ ///
+ ///
+ /// Schemas with no $ref parse identically to ;
+ /// the resolver is never consulted for them, so behavior is unchanged.
+ ///
+ ///
+ /// The definition JSON; null/whitespace yields null.
+ ///
+ /// Optional reference-resolution seam mapping a lib:Name target's name to
+ /// the referenced schema JSON (or null when not found). null means
+ /// no resolver: a $ref then dangles. The seam keeps this Commons type free
+ /// of any repository dependency — the caller (the validation layer) supplies it.
+ ///
+ /// The parsed (and ref-resolved) schema, or null when the input is empty.
+ /// The input is invalid JSON, a root scalar/null, exceeds , or contains a dangling/cyclic/over-depth $ref.
+ public static InboundApiSchema? Parse(string? json, Func? 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;
+ }
+
+ ///
+ /// Parses a stored definition string into an ,
+ /// resolving {"$ref":"lib:Name"} library references through the
+ /// caller-supplied seam, and COLLECTING (rather than
+ /// throwing on) any references that cannot be resolved (M9-T32b).
+ ///
+ ///
+ /// This is the deploy-time entry point: a dangling, cyclic, or over-depth
+ /// $ref is reported in so the
+ /// validation layer can surface it as a deploy-blocking error naming the missing
+ /// reference, instead of aborting the whole parse. See
+ /// for the throwing variant and for the lib:Name pointer convention.
+ ///
+ ///
+ /// The definition JSON; null/whitespace yields a result with a null schema and no unresolved refs.
+ ///
+ /// Optional reference-resolution seam (see ).
+ /// null means no resolver: any $ref is reported as unresolved.
+ ///
+ /// The parsed schema (refs resolved where possible) plus the names of any references that could not be resolved.
+ /// The input is invalid JSON, a root scalar/null, or exceeds the structural for non-ref nesting.
+ public static SchemaParseResult ParseWithRefs(string? json, Func? resolveRef)
{
if (string.IsNullOrWhiteSpace(json))
{
- return null;
+ return new SchemaParseResult(null, []);
}
+ var unresolved = new List();
+ // 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(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)
+ ///
+ /// Carries the $ref 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 null means no resolver was supplied —
+ /// every $ref then dangles.
+ ///
+ private sealed record RefResolutionContext(
+ Func? Resolver,
+ List Unresolved,
+ HashSet 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 };
}
+ /// The lib: scheme prefix on a $ref value identifying a library reference.
+ private const string LibRefScheme = "lib:";
+
+ /// The placeholder type for an unresolvable $ref node (dangling, cyclic, or over-depth).
+ private const string UnresolvedRefType = "ref";
+
+ ///
+ /// Recognizes a {"$ref":"lib:Name"} reference node and extracts its target
+ /// name (the part after the lib: scheme prefix). Returns false for any
+ /// node that is not a lib: reference, so non-ref schemas take the normal path.
+ ///
+ /// The schema node to inspect.
+ /// The resolved target name (after lib:) when this is a lib: ref; otherwise empty.
+ /// true when is a lib: $ref node with a non-empty target name.
+ 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;
+ }
+
+ ///
+ /// Resolves a lib:Name reference through the seam, parsing the referenced
+ /// schema (which may itself contain $refs). 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.
+ ///
+ /// The library entry name to resolve.
+ /// The current structural depth (shared with the guard).
+ /// The active resolution context (seam, unresolved accumulator, cycle guard).
+ /// The resolved schema, or a placeholder ref-typed schema when unresolvable.
+ 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();
@@ -391,3 +590,20 @@ public sealed class InboundApiSchema
/// Whether the field must be present.
/// The recursive type schema the field's value must satisfy.
public sealed record InboundApiSchemaField(string Name, bool Required, InboundApiSchema Schema);
+
+///
+/// The outcome of (M9-T32b): the parsed
+/// schema (with {"$ref":"lib:Name"} references resolved where possible) plus the
+/// names of any references that could NOT be resolved — dangling (the seam returned
+/// null or no seam was supplied), cyclic, or over-depth. A non-empty
+/// is the deploy-blocking signal the validation layer acts on.
+///
+/// The parsed schema, or null when the input was empty.
+///
+/// The reference targets that could not be resolved, each annotated with the reason for
+/// cyclic/over-depth cases (e.g. "Foo (cyclic reference)"). Empty when every
+/// reference resolved.
+///
+public sealed record SchemaParseResult(
+ InboundApiSchema? Schema,
+ IReadOnlyList UnresolvedRefs);
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs
index a036b348..10543191 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs
@@ -22,6 +22,7 @@ public class FlatteningPipeline : IFlatteningPipeline
private readonly FlatteningService _flatteningService;
private readonly ValidationService _validationService;
private readonly RevisionHashService _revisionHashService;
+ private readonly ISharedSchemaRepository _sharedSchemaRepo;
/// Initializes a new with the required template engine and site repositories and services.
/// Repository for loading templates and instance data.
@@ -29,18 +30,25 @@ public class FlatteningPipeline : IFlatteningPipeline
/// Service that flattens the template inheritance chain into a resolved config.
/// Service that performs semantic validation on the flattened config.
/// Service that computes the revision hash for staleness detection.
+ ///
+ /// M9-T32b: repository backing the JSON-Schema $ref resolution seam. Used to
+ /// look up lib:Name library references so a dangling reference in any validated
+ /// script schema becomes a deploy-blocking error.
+ ///
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;
}
///
@@ -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 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);
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index eb2bad7d..85f0b005 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -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();
+ var sharedSchemas = await sharedSchemaRepo.ListAsync();
+ var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
+ Func 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();
diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs
index 59e60bc3..ea2bb00c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs
@@ -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. null skips the "exists at site" half (it stays inert).
///
+ ///
+ /// M9-T32b: optional JSON-Schema $ref resolution seam mapping a
+ /// lib:Name reference target's name to the referenced schema JSON (or
+ /// null when the library entry does not exist). Supplied on the deploy path
+ /// (backed by ISharedSchemaRepository) so a dangling/cyclic/over-depth
+ /// $ref in any validated script parameter/return schema becomes a
+ /// deploy-blocking error naming the
+ /// missing reference. When null the seam is absent: schemas with no
+ /// $ref are unaffected (behavior unchanged), and a $ref with no
+ /// resolver is treated as dangling (the safe option).
+ ///
/// A merged aggregating all pipeline stage outcomes.
public ValidationResult Validate(
FlattenedConfiguration configuration,
IReadOnlyList? sharedScripts = null,
IReadOnlySet? alarmCapableConnectionNames = null,
bool enforceConnectionBindings = false,
- IReadOnlySet? siteConnectionNames = null)
+ IReadOnlySet? siteConnectionNames = null,
+ Func? 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());
}
+ ///
+ /// M9-T32b — JSON-Schema $ref resolution check. Parses every script
+ /// parameter/return schema (instance scripts and the supplied shared scripts)
+ /// through , resolving any
+ /// {"$ref":"lib:Name"} reference via .
+ /// A reference that cannot be resolved — dangling (the seam returns null or
+ /// none is supplied), cyclic, or over-depth — is a deploy-blocking
+ /// error naming the missing
+ /// reference and the owning script.
+ ///
+ ///
+ /// The check is INERT for schemas that contain no $ref:
+ /// 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-$ref 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.
+ ///
+ ///
+ /// The flattened configuration whose scripts' schemas are checked.
+ /// Optional shared scripts whose schemas are also checked.
+ /// The $ref resolution seam, or null (no resolver → refs dangle).
+ /// A with one error per unresolved reference, or success.
+ internal static ValidationResult ValidateSchemaReferences(
+ FlattenedConfiguration configuration,
+ IReadOnlyList? sharedScripts,
+ Func? resolveSchemaRef)
+ {
+ var errors = new List();
+
+ 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));
+ }
+ }
+ }
+
///
/// Validates that flattening produced a non-empty configuration.
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs
new file mode 100644
index 00000000..fb766143
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs
@@ -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;
+
+///
+/// Tests for the M9-T32b JSON-Schema $ref resolver: the caller-supplied
+/// resolution seam (lib:Name pointer convention), cycle/depth guarding,
+/// and dangling-ref reporting. The resolver is OPTIONAL — schemas without a
+/// $ref parse exactly as before (verified by the no-ref-unchanged tests).
+///
+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(() =>
+ 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
+ {
+ ["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
+ {
+ ["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
+ {
+ ["Foo"] = """{"$ref":"lib:Foo"}""",
+ };
+
+ Assert.Throws(() =>
+ 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();
+ 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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineConnectionBindingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineConnectionBindingTests.cs
index 49e7e897..35648a15 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineConnectionBindingTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineConnectionBindingTests.cs
@@ -26,16 +26,21 @@ public class FlatteningPipelineConnectionBindingTests
private readonly ITemplateEngineRepository _templateRepo = Substitute.For();
private readonly ISiteRepository _siteRepo = Substitute.For();
+ private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For();
private readonly FlatteningPipeline _sut;
public FlatteningPipelineConnectionBindingTests()
{
+ _sharedSchemaRepo.ListAsync(Arg.Any())
+ .Returns([]);
+
_sut = new FlatteningPipeline(
_templateRepo,
_siteRepo,
new FlatteningService(),
new ValidationService(),
- new RevisionHashService());
+ new RevisionHashService(),
+ _sharedSchemaRepo);
}
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs
index a49f35c3..885af4ac 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs
@@ -26,16 +26,21 @@ public class FlatteningPipelineNativeAlarmCapabilityTests
private readonly ITemplateEngineRepository _templateRepo = Substitute.For();
private readonly ISiteRepository _siteRepo = Substitute.For();
+ private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For();
private readonly FlatteningPipeline _sut;
public FlatteningPipelineNativeAlarmCapabilityTests()
{
+ _sharedSchemaRepo.ListAsync(Arg.Any())
+ .Returns([]);
+
_sut = new FlatteningPipeline(
_templateRepo,
_siteRepo,
new FlatteningService(),
new ValidationService(),
- new RevisionHashService());
+ new RevisionHashService(),
+ _sharedSchemaRepo);
}
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SchemaRefValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SchemaRefValidationTests.cs
new file mode 100644
index 00000000..c9ac2013
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SchemaRefValidationTests.cs
@@ -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;
+
+///
+/// M9-T32b deploy-time validation: a dangling {"$ref":"lib:Name"} in any
+/// validated script parameter/return schema must BLOCK deployment (a
+/// error naming the missing ref).
+/// A valid ref resolved through the supplied seam passes. The check is inert for
+/// schemas with no $ref (existing behaviour unchanged) and when no resolver
+/// is supplied and there are no refs.
+///
+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
+ {
+ ["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);
+ }
+}