diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs index 58abe95c..f66c4106 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs @@ -21,10 +21,16 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; /// request carry back the token from the prior . /// Additive (appended last) so positional construction stays source-compatible. /// +/// +/// Target site identifier — REQUIRED when the command is invoked via the management +/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where +/// routing already happened. Additive per the message-contract evolution rules. +/// public record BrowseNodeCommand( string ConnectionName, string? ParentNodeId, - string? ContinuationToken = null); + string? ContinuationToken = null, + string? SiteIdentifier = null); /// Immediate children resolved for the browsed node (this page only). /// True when the result was clipped (frame-budget cap or adapter-reported truncation). @@ -59,11 +65,17 @@ public record BrowseNodeResult( /// Case-insensitive substring matched against each node's DisplayName and root-relative path. /// Maximum number of levels below the root to descend. Must be non-negative. /// Maximum number of matches to return; when reached the walk stops early and is set. +/// +/// Target site identifier — REQUIRED when the command is invoked via the management +/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where +/// routing already happened. Additive per the message-contract evolution rules. +/// public record SearchAddressSpaceCommand( string ConnectionName, string Query, int MaxDepth, - int MaxResults); + int MaxResults, + string? SiteIdentifier = null); /// The matched address-space nodes, in breadth-first discovery order (capped at ). /// True when a bound (result cap or the adapter's node-visit ceiling) cut the walk short, so more matches may exist than were returned. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs index 30b5e6dc..20450de6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs @@ -22,21 +22,36 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; /// The data-connection the certificate was captured from (diagnostics / correlation only). /// The server certificate's DER encoding, base64-encoded. /// The certificate thumbprint — used as the store filename key. -public record TrustServerCertCommand(string ConnectionName, string DerBase64, string Thumbprint); +/// +/// Target site identifier — REQUIRED when the command is invoked via the management +/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where +/// routing already happened. Additive per the message-contract evolution rules. +/// +public record TrustServerCertCommand(string ConnectionName, string DerBase64, string Thumbprint, string? SiteIdentifier = null); /// /// Remove a previously-trusted OPC UA server certificate from every site /// node's trusted-peer PKI store, identified by thumbprint. /// /// The thumbprint of the certificate to remove. -public record RemoveServerCertCommand(string Thumbprint); +/// +/// Target site identifier — REQUIRED when the command is invoked via the management +/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where +/// routing already happened. Additive per the message-contract evolution rules. +/// +public record RemoveServerCertCommand(string Thumbprint, string? SiteIdentifier = null); /// /// List the certificates currently present in this site's trusted-peer and /// rejected PKI stores. Answered from the local node (the Deployment Manager /// singleton's own node). /// -public record ListServerCertsCommand(); +/// +/// Target site identifier — REQUIRED when the command is invoked via the management +/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where +/// routing already happened. Additive per the message-contract evolution rules. +/// +public record ListServerCertsCommand(string? SiteIdentifier = null); /// /// Read-only projection of a certificate found in a site PKI store. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/VerifyEndpointCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/VerifyEndpointCommands.cs index 946251b3..a0e2f438 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/VerifyEndpointCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/VerifyEndpointCommands.cs @@ -12,7 +12,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; /// Name of the data connection being verified (for logging/correlation). /// Protocol type string (e.g. "OpcUa"); matched case-insensitively. /// Serialized endpoint configuration JSON (the typed OPC UA endpoint shape). -public record VerifyEndpointCommand(string ConnectionName, string Protocol, string ConfigJson); +/// +/// Target site identifier — REQUIRED when the command is invoked via the management +/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where +/// routing already happened. Additive per the message-contract evolution rules. +/// +public record VerifyEndpointCommand(string ConnectionName, string Protocol, string ConfigJson, string? SiteIdentifier = null); /// /// Classification of why an endpoint verification failed. Distinguishes the cases the diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseVerifyCertSiteIdentifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseVerifyCertSiteIdentifierTests.cs new file mode 100644 index 00000000..9ffc84cd --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseVerifyCertSiteIdentifierTests.cs @@ -0,0 +1,134 @@ +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; + +/// +/// Task 21 (PLAN-07): the additive optional SiteIdentifier trailing param on the +/// Browse/Verify/Cert-trust management commands. Verifies backward compatibility (legacy +/// JSON without the field deserializes to null) and round-trip survival when set. +/// +public class BrowseVerifyCertSiteIdentifierTests +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true + }; + + // ── Backward compatibility: legacy JSON (no SiteIdentifier) → null ── + + [Fact] + public void BrowseNodeCommand_LegacyJson_SiteIdentifierNull() + { + var json = """{"ConnectionName":"conn","ParentNodeId":"ns=2;s=Node","ContinuationToken":null}"""; + var cmd = JsonSerializer.Deserialize(json, Options)!; + Assert.Null(cmd.SiteIdentifier); + Assert.Equal("conn", cmd.ConnectionName); + } + + [Fact] + public void SearchAddressSpaceCommand_LegacyJson_SiteIdentifierNull() + { + var json = """{"ConnectionName":"conn","Query":"pump","MaxDepth":5,"MaxResults":100}"""; + var cmd = JsonSerializer.Deserialize(json, Options)!; + Assert.Null(cmd.SiteIdentifier); + Assert.Equal("pump", cmd.Query); + } + + [Fact] + public void VerifyEndpointCommand_LegacyJson_SiteIdentifierNull() + { + var json = """{"ConnectionName":"conn","Protocol":"OpcUa","ConfigJson":"{}"}"""; + var cmd = JsonSerializer.Deserialize(json, Options)!; + Assert.Null(cmd.SiteIdentifier); + Assert.Equal("OpcUa", cmd.Protocol); + } + + [Fact] + public void TrustServerCertCommand_LegacyJson_SiteIdentifierNull() + { + var json = """{"ConnectionName":"conn","DerBase64":"AAAA","Thumbprint":"ABCD"}"""; + var cmd = JsonSerializer.Deserialize(json, Options)!; + Assert.Null(cmd.SiteIdentifier); + Assert.Equal("ABCD", cmd.Thumbprint); + } + + [Fact] + public void RemoveServerCertCommand_LegacyJson_SiteIdentifierNull() + { + var json = """{"Thumbprint":"ABCD"}"""; + var cmd = JsonSerializer.Deserialize(json, Options)!; + Assert.Null(cmd.SiteIdentifier); + Assert.Equal("ABCD", cmd.Thumbprint); + } + + [Fact] + public void ListServerCertsCommand_LegacyJson_SiteIdentifierNull() + { + var json = "{}"; + var cmd = JsonSerializer.Deserialize(json, Options)!; + Assert.Null(cmd.SiteIdentifier); + } + + // ── Round-trip: SiteIdentifier set survives serialize → deserialize ── + + [Fact] + public void BrowseNodeCommand_RoundTrip_SiteIdentifierSurvives() + { + var original = new BrowseNodeCommand("conn", "ns=2;s=Node", "tok", "SITE-01"); + var json = JsonSerializer.Serialize(original, Options); + var round = JsonSerializer.Deserialize(json, Options)!; + Assert.Equal("SITE-01", round.SiteIdentifier); + Assert.Equal(original, round); + } + + [Fact] + public void SearchAddressSpaceCommand_RoundTrip_SiteIdentifierSurvives() + { + var original = new SearchAddressSpaceCommand("conn", "pump", 5, 100, "SITE-01"); + var json = JsonSerializer.Serialize(original, Options); + var round = JsonSerializer.Deserialize(json, Options)!; + Assert.Equal("SITE-01", round.SiteIdentifier); + Assert.Equal(original, round); + } + + [Fact] + public void VerifyEndpointCommand_RoundTrip_SiteIdentifierSurvives() + { + var original = new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE-01"); + var json = JsonSerializer.Serialize(original, Options); + var round = JsonSerializer.Deserialize(json, Options)!; + Assert.Equal("SITE-01", round.SiteIdentifier); + Assert.Equal(original, round); + } + + [Fact] + public void TrustServerCertCommand_RoundTrip_SiteIdentifierSurvives() + { + var original = new TrustServerCertCommand("conn", "AAAA", "ABCD", "SITE-01"); + var json = JsonSerializer.Serialize(original, Options); + var round = JsonSerializer.Deserialize(json, Options)!; + Assert.Equal("SITE-01", round.SiteIdentifier); + Assert.Equal(original, round); + } + + [Fact] + public void RemoveServerCertCommand_RoundTrip_SiteIdentifierSurvives() + { + var original = new RemoveServerCertCommand("ABCD", "SITE-01"); + var json = JsonSerializer.Serialize(original, Options); + var round = JsonSerializer.Deserialize(json, Options)!; + Assert.Equal("SITE-01", round.SiteIdentifier); + Assert.Equal(original, round); + } + + [Fact] + public void ListServerCertsCommand_RoundTrip_SiteIdentifierSurvives() + { + var original = new ListServerCertsCommand("SITE-01"); + var json = JsonSerializer.Serialize(original, Options); + var round = JsonSerializer.Deserialize(json, Options)!; + Assert.Equal("SITE-01", round.SiteIdentifier); + Assert.Equal(original, round); + } +}