From b3d99248fa8608d6d7c2dc6773c4769ff5e81b0f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 11:54:19 -0400 Subject: [PATCH] feat(m9/T32b): JSON Schema $ref resolver (lib seam, cycle/depth-guarded) + deploy-time dangling-ref block --- .../Types/Flattening/ValidationResult.cs | 9 +- .../Types/InboundApi/InboundApiSchema.cs | 230 +++++++++++++++++- .../FlatteningPipeline.cs | 22 +- .../ManagementActor.cs | 11 +- .../Validation/ValidationService.cs | 89 ++++++- .../InboundApi/InboundApiSchemaRefTests.cs | 222 +++++++++++++++++ ...latteningPipelineConnectionBindingTests.cs | 7 +- ...eningPipelineNativeAlarmCapabilityTests.cs | 7 +- .../Validation/SchemaRefValidationTests.cs | 172 +++++++++++++ 9 files changed, 755 insertions(+), 14 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SchemaRefValidationTests.cs 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); + } +}