From 46ebdbf393a61276459f3ea99d35ea0ecdaa1dcb Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Sun, 9 Jun 2024 07:57:08 +0200 Subject: [PATCH] Fix Use security and twin tests (#2239) --- docs/opc-publisher/commandline.md | 13 +- .../TestExtensions/IIoTPlatformTestContext.cs | 3 + .../IIoTPlatform-E2E-Tests/TestHelper.cs | 32 ++- .../Twin/TwinBrowseTestTheory.cs | 35 +++- .../src/PublishedNodesEntryModel.cs | 4 +- .../src/SecurityMode.cs | 10 +- .../PublishNodesEndpointApiModelTests.cs | 4 +- .../cli/Program.cs | 1 - .../src/Runtime/CommandLine.cs | 2 +- .../DmApiPublisherControllerTests.cs | 2 +- .../Extensions/EndpointRegistrationEx.cs | 6 +- .../Services/Models/EndpointRegistration.cs | 2 +- .../src/Runtime/PublisherConfig.cs | 2 + .../Stack/Extensions/EndpointDescriptionEx.cs | 3 +- .../src/Stack/Extensions/StackTypesEx.cs | 20 +- .../src/Stack/Services/OpcUaClient.cs | 7 +- .../src/Storage/PublishedNodesConverter.cs | 24 ++- .../Services/PublisherConfigServicesTests.cs | 184 ++++++++---------- .../Publisher/Extensions/EndpointModelEx.cs | 6 +- .../Extensions/PublishedNodesEntryModelEx.cs | 18 +- .../Models/PublishedNodesEntryModelTests.cs | 22 ++- 21 files changed, 231 insertions(+), 169 deletions(-) diff --git a/docs/opc-publisher/commandline.md b/docs/opc-publisher/commandline.md index faa8f3d618..a2a79e05c2 100644 --- a/docs/opc-publisher/commandline.md +++ b/docs/opc-publisher/commandline.md @@ -280,6 +280,7 @@ Messaging configuration Allowed values: `IoTHub` `Mqtt` + `EventHub` `Dapr` `Http` `FileSystem` @@ -323,6 +324,15 @@ Transport settings `Any` Default: `Mqtt` if device or edge hub connection string is provided, ignored otherwise. + --eh, --eventhubnamespaceconnectionstring, --EventHubNamespaceConnectionString=VALUE + The connection string of an existing event hub + namespace to use for the Azure EventHub + transport. + Default: `not set`. + --sg, --schemagroup, --SchemaGroupName=VALUE + The schema group in an event hub namespace to + publish message schemas to. + Default: `not set`. -d, --dcs, --daprconnectionstring, --DaprConnectionString=VALUE Connect the OPC Publisher to a dapr pub sub component using a connection string. @@ -331,7 +341,8 @@ Transport settings side car connection if needed. Use the format 'PubSubComponent= [;GrpcPort=;HttpPort=[; - Scheme=<'https'|'http'>][;Host=]][;ApiKey=]'. + Scheme=<'https'|'http'>][;Host=]][; + ApiKey=]'. To publish through dapr by default specify `-t= Dapr`. Default: `not set`. diff --git a/e2e-tests/IIoTPlatform-E2E-Tests/TestExtensions/IIoTPlatformTestContext.cs b/e2e-tests/IIoTPlatform-E2E-Tests/TestExtensions/IIoTPlatformTestContext.cs index 1a7c8b730a..9d9f65fd14 100644 --- a/e2e-tests/IIoTPlatform-E2E-Tests/TestExtensions/IIoTPlatformTestContext.cs +++ b/e2e-tests/IIoTPlatform-E2E-Tests/TestExtensions/IIoTPlatformTestContext.cs @@ -239,6 +239,9 @@ private static IConfigurationRoot GetConfiguration() public string ImagesTag => GetStringOrDefault(TestConstants.EnvironmentVariablesNames.PCS_IMAGES_TAG, () => "latest"); + public string Token { get; internal set; } + public DateTime TokenExpiration { get; internal set; } + public void LogEnvironment(ITestOutputHelper output) { if (output == null || _logged || output is DummyOutput) diff --git a/e2e-tests/IIoTPlatform-E2E-Tests/TestHelper.cs b/e2e-tests/IIoTPlatform-E2E-Tests/TestHelper.cs index 6228c9fc16..421f55f252 100644 --- a/e2e-tests/IIoTPlatform-E2E-Tests/TestHelper.cs +++ b/e2e-tests/IIoTPlatform-E2E-Tests/TestHelper.cs @@ -6,6 +6,7 @@ namespace IIoTPlatformE2ETests { using Azure.Messaging.EventHubs.Consumer; + using Microsoft.Azure.Devices.Common.Exceptions; using Microsoft.Azure.Devices; using Newtonsoft.Json; using IIoTPlatformE2ETests.Config; @@ -30,7 +31,6 @@ namespace IIoTPlatformE2ETests using Azure.IIoT.OpcUa.Publisher.Models; using Newtonsoft.Json.Converters; using Xunit.Sdk; - using Microsoft.Azure.Devices.Common.Exceptions; public record class MethodResultModel(string JsonPayload, int Status); public record class MethodParameterModel @@ -52,13 +52,21 @@ public static async Task GetTokenAsync( CancellationToken ct = default ) { - return await GetTokenAsync( + if (context.Token != null && (DateTime.UtcNow + TimeSpan.FromSeconds(10) < context.TokenExpiration)) + { + return context.Token; + } + var (expiration, token) = await GetTokenAsync( context.IIoTPlatformConfigHubConfig.AuthTenant, context.IIoTPlatformConfigHubConfig.AuthClientId, context.IIoTPlatformConfigHubConfig.AuthClientSecret, context.IIoTPlatformConfigHubConfig.AuthServiceId, + context.OutputHelper, ct ).ConfigureAwait(false); + context.Token = token; + context.TokenExpiration = expiration; + return token; } /// @@ -70,11 +78,12 @@ public static async Task GetTokenAsync( /// service id of deployed Industrial IoT /// Cancellation token /// Return content of request token or empty string - public static async Task GetTokenAsync( + public static async Task<(DateTime Expiration, string Token)> GetTokenAsync( string tenantId, string clientId, string clientSecret, string serviceId, + ITestOutputHelper outputHelper, CancellationToken ct = default ) { @@ -84,7 +93,7 @@ public static async Task GetTokenAsync( Assert.False(string.IsNullOrWhiteSpace(serviceId)); Exception saved = new UnauthorizedAccessException(); - for (var i = 0; i < 3; i++) + for (var i = 0; i < 10; i++) { try { @@ -104,12 +113,21 @@ public static async Task GetTokenAsync( dynamic json = JsonConvert.DeserializeObject(response.Content); Assert.NotNull(json); Assert.NotEmpty(json); - return $"{json.token_type} {json.access_token}"; + + var expiration = DateTime.UtcNow.AddMinutes(1); + try + { + var seconds = (int)json.expires_in; + expiration = DateTime.UtcNow.AddSeconds(seconds); + outputHelper?.WriteLine($"Retrieved access token, token expires in {seconds} sec."); + } + catch { } + return (DateTime.UtcNow.AddMinutes(5), $"{json.token_type} {json.access_token}"); } catch (Exception ex) { saved = ex; - Console.WriteLine($"Error occurred while requesting token: {ex.Message}"); + outputHelper?.WriteLine($"Error occurred while requesting token: {ex.Message}"); await Task.Delay(1000, ct).ConfigureAwait(false); } } @@ -175,7 +193,7 @@ public static async Task> GetSimul private static async Task GetPublishedNodesEntryModel(string host, CancellationToken ct) { - for (var attempt = 0; ; attempt++) + for (var attempt = 0; ; attempt++) { try { diff --git a/e2e-tests/IIoTPlatform-E2E-Tests/Twin/TwinBrowseTestTheory.cs b/e2e-tests/IIoTPlatform-E2E-Tests/Twin/TwinBrowseTestTheory.cs index e10634e50f..66e4156311 100644 --- a/e2e-tests/IIoTPlatform-E2E-Tests/Twin/TwinBrowseTestTheory.cs +++ b/e2e-tests/IIoTPlatform-E2E-Tests/Twin/TwinBrowseTestTheory.cs @@ -8,6 +8,7 @@ namespace IIoTPlatformE2ETests.Twin using IIoTPlatformE2ETests.TestExtensions; using System; using System.Collections.Generic; + using System.Linq; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -117,11 +118,18 @@ public async Task BrowseAllNodesOfTypeObject() Assert.True(nodes.Count > 150); - Assert.Contains(nodes, n => string.Equals("i=85", n.NodeId, StringComparison.Ordinal)); - Assert.Contains(nodes, n => string.Equals("i=2253", n.NodeId, StringComparison.Ordinal)); - Assert.Contains(nodes, n => string.Equals("nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=5", n.NodeId, StringComparison.Ordinal)); - Assert.Contains(nodes, n => string.Equals("nsu=http://microsoft.com/Opc/OpcPlc/;s=OpcPlc", n.NodeId, StringComparison.Ordinal)); - Assert.Contains(nodes, n => string.Equals("i=15668", n.NodeId, StringComparison.Ordinal)); + + var set = new HashSet + { + "i=2253", + "nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=5", + "nsu=http://microsoft.com/Opc/OpcPlc/;s=OpcPlc", + "i=15668", + "nsu=http://microsoft.com/Opc/OpcPlc/ReferenceTest;s=Scalar_Static_Mass_Boolean", + }; + var counted = nodes.Count(n => set.Contains(n.NodeId)); + _context.OutputHelper.WriteLine($"Found count is {counted}"); + Assert.Equal(set.Count, counted); } catch { @@ -144,11 +152,18 @@ public async Task BrowseAllNodesOfTypeVariable() Assert.NotNull(nodes); Assert.NotEmpty(nodes); - Assert.True(nodes.Count > 150); - - Assert.Contains(nodes, n => string.Equals("i=2254", n.NodeId, StringComparison.Ordinal)); - Assert.Contains(nodes, n => string.Equals("nsu=http://microsoft.com/Opc/OpcPlc/;s=LongString1", n.NodeId, StringComparison.Ordinal)); - Assert.Contains(nodes, n => string.Equals("nsu=http://microsoft.com/Opc/OpcPlc/;s=SlowUInt1", n.NodeId, StringComparison.Ordinal)); + Assert.True(nodes.Count > 5000); + + var set = new HashSet + { + "i=2254", + "nsu=http://microsoft.com/Opc/OpcPlc/;s=LongString10kB", + "nsu=http://microsoft.com/Opc/OpcPlc/;s=SlowUInt1", + "nsu=http://microsoft.com/Opc/OpcPlc/ReferenceTest;s=Scalar_Simulation_Mass_Boolean_Boolean_22" + }; + var counted = nodes.Count(n => set.Contains(n.NodeId)); + _context.OutputHelper.WriteLine($"Found count is {counted}"); + Assert.Equal(set.Count, counted); } catch { diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryModel.cs index b93615f122..facb20fcc0 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryModel.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryModel.cs @@ -161,7 +161,7 @@ public sealed record class PublishedNodesEntryModel /// endpoint. Overrides setting. /// If the security mode is not available with any /// configured security policy connectivity will fail. - /// Default: if + /// Default: if /// is true, /// otherwise /// @@ -264,7 +264,7 @@ public sealed record class PublishedNodesEntryModel /// the opc server. /// [DataMember(Name = "UseSecurity", Order = 30)] - public bool UseSecurity { get; set; } + public bool? UseSecurity { get; set; } /// /// authentication mode diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/SecurityMode.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/SecurityMode.cs index 2a77c69081..0fe2dddc07 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Models/src/SecurityMode.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/SecurityMode.cs @@ -5,12 +5,14 @@ namespace Azure.IIoT.OpcUa.Publisher.Models { + using System; using System.Runtime.Serialization; /// /// Security mode of endpoint /// [DataContract] + [Flags] public enum SecurityMode { /// @@ -35,6 +37,12 @@ public enum SecurityMode /// No security /// [EnumMember(Value = "None")] - None + None, + + /// + /// Use sign or sign and encrypt + /// + [EnumMember(Value = "NotNone")] + NotNone, } } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/tests/PublishNodesEndpointApiModelTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/tests/PublishNodesEndpointApiModelTests.cs index 5876f7d1bd..9403ef2f02 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Models/tests/PublishNodesEndpointApiModelTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/tests/PublishNodesEndpointApiModelTests.cs @@ -29,7 +29,7 @@ public void UseSecurityDeserializationTest() """; var model = newtonSoftJsonSerializer.Deserialize(modelJson); - Assert.False(model.UseSecurity); + Assert.Null(model.UseSecurity); modelJson = """ @@ -78,7 +78,7 @@ public void UseSecuritySerializationTest() }; var modeJson = newtonSoftJsonSerializer.SerializeToString(model); - Assert.Contains("\"UseSecurity\":false", modeJson, StringComparison.Ordinal); + Assert.DoesNotContain("\"UseSecurity\":false", modeJson, StringComparison.Ordinal); model = new PublishedNodesEntryModel { diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs index 7ec9a0d5de..cb7b21693a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs @@ -532,7 +532,6 @@ static async Task RunAsync(ILoggerFactory loggerFactory, string publishProfile, { "-c", "--ps", - $"--ssf={outputFolder}", $"--pf={publishedNodesFilePath}", $"--me={messageProfile.MessageEncoding}", $"--mm={messageProfile.MessagingMode}", diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs index dc2a161828..ea46fc68c4 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs @@ -248,7 +248,7 @@ public CommandLine(string[] args, CommandLineLogger? logger = null) t => this[PublisherConfig.DiagnosticsTopicTemplateKey] = t }, { $"mdt|metadatatopictemplate:|{PublisherConfig.DataSetMetaDataTopicTemplateKey}:", "The topic that metadata should be sent to.\nIn case of MQTT the message will be sent as RETAIN message with a TTL of either metadata send interval or infinite if metadata send interval is not configured.\nOnly valid if metadata is supported and/or explicitely enabled.\nThe template variables\n `{{RootTopic}}`\n `{{SiteId}}`\n `{{TelemetryTopic}}`\n `{{Encoding}}`\n `{{PublisherId}}`\n `{{DataSetClassId}}`\n `{{DataSetWriter}}` and\n `{{WriterGroup}}`\ncan be used as dynamic parts in the template. \nDefault: `{{TelemetryTopic}}` which means metadata is sent to the same output as regular messages. If specified without value, the default output is `{{TelemetryTopic}}/metadata`.\n", - s => this[PublisherConfig.DataSetMetaDataTopicTemplateKey] = !string.IsNullOrEmpty(s) ? s : "{TelemetryTopic}/metadata" }, + s => this[PublisherConfig.DataSetMetaDataTopicTemplateKey] = !string.IsNullOrEmpty(s) ? s : PublisherConfig.MetadataTopicTemplateDefault }, { $"uns|datasetrouting=|{PublisherConfig.DefaultDataSetRoutingKey}=", $"Configures whether messages should automatically be routed using the browse path of the monitored item inside the address space starting from the RootFolder.\nThe browse path is appended as topic structure to the telemetry topic root which can be configured using `--ttt`. Reserved characters in browse names are escaped with their hex ASCII code.\nAllowed values:\n `{string.Join("`\n `", Enum.GetNames(typeof(DataSetRoutingMode)))}`\nDefault: `{nameof(DataSetRoutingMode.None)}` (Topics must be configured).\n", (DataSetRoutingMode m) => this[PublisherConfig.DefaultDataSetRoutingKey] = m.ToString() }, diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/DmApiPublisherControllerTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/DmApiPublisherControllerTests.cs index d40bb18749..cb14c7477c 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/DmApiPublisherControllerTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/DmApiPublisherControllerTests.cs @@ -262,7 +262,7 @@ await FluentActions writer => Assert.Equal(publishNodesRequests[0].EndpointUrl, writer.DataSet.DataSetSource.Connection.Endpoint.Url)); Assert.Equal(publishNodesRequests - .Select(n => n.UseSecurity ? SecurityMode.SignAndEncrypt : SecurityMode.None) + .Select(n => (n.UseSecurity ?? false) ? SecurityMode.SignAndEncrypt : SecurityMode.None) .ToHashSet(), writerGroup.DataSetWriters .Select(w => w.DataSet.DataSetSource.Connection.Endpoint.SecurityMode.Value) diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service/src/Services/Extensions/EndpointRegistrationEx.cs b/src/Azure.IIoT.OpcUa.Publisher.Service/src/Services/Extensions/EndpointRegistrationEx.cs index c854bd1547..3a7ea3b51f 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service/src/Services/Extensions/EndpointRegistrationEx.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Service/src/Services/Extensions/EndpointRegistrationEx.cs @@ -320,7 +320,7 @@ public static DeviceTwinModel Patch(this EndpointRegistration? existing, Url = string.IsNullOrEmpty(registration.EndpointUrl) ? registration.EndpointUrlLC : registration.EndpointUrl, AlternativeUrls = registration.AlternativeUrls?.DecodeAsList().ToHashSetSafe(), - SecurityMode = registration.SecurityMode == SecurityMode.Best ? + SecurityMode = registration.SecurityMode == SecurityMode.NotNone ? null : registration.SecurityMode, SecurityPolicy = string.IsNullOrEmpty(registration.SecurityPolicy) ? null : registration.SecurityPolicy, @@ -360,7 +360,7 @@ public static EndpointRegistration ToEndpointRegistration(this EndpointInfoModel AuthenticationMethods = model.Registration?.AuthenticationMethods? .EncodeAsDictionary(), SecurityMode = model.Registration?.Endpoint?.SecurityMode ?? - SecurityMode.Best, + SecurityMode.NotNone, SecurityPolicy = model.Registration?.Endpoint?.SecurityPolicy, Thumbprint = model.Registration?.Endpoint?.Certificate }; @@ -454,7 +454,7 @@ public int GetHashCode(EndpointRegistration obj) EqualityComparer.Default.GetHashCode(obj.ApplicationId ?? string.Empty); hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode( - obj.SecurityMode ?? SecurityMode.Best); + obj.SecurityMode ?? SecurityMode.NotNone); hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(obj.SecurityPolicy ?? string.Empty); return hashCode; diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service/src/Services/Models/EndpointRegistration.cs b/src/Azure.IIoT.OpcUa.Publisher.Service/src/Services/Models/EndpointRegistration.cs index 5176a15a51..6c10292532 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service/src/Services/Models/EndpointRegistration.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Service/src/Services/Models/EndpointRegistration.cs @@ -186,7 +186,7 @@ public override int GetHashCode() EqualityComparer.Default.GetHashCode(SecurityLevel ?? 0); hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode( - SecurityMode ?? Publisher.Models.SecurityMode.Best); + SecurityMode ?? Publisher.Models.SecurityMode.NotNone); hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(SecurityPolicy ?? string.Empty); return hashCode; diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherConfig.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherConfig.cs index f933bb316f..6147ef9848 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherConfig.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherConfig.cs @@ -99,6 +99,8 @@ public sealed class PublisherConfig : PostConfigureOptionBase $"{{{RootTopicVariableName}}}/methods"; public const string EventsTopicTemplateDefault = $"{{{RootTopicVariableName}}}/events"; + public const string MetadataTopicTemplateDefault = + $"{{{TelemetryTopicVariableName}}}/metadata"; public const string DiagnosticsTopicTemplateDefault = $"{{{RootTopicVariableName}}}/diagnostics/{{{WriterGroupVariableName}}}"; public const string RootTopicTemplateDefault = diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/EndpointDescriptionEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/EndpointDescriptionEx.cs index a1c2f30c2b..f9903cd9d7 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/EndpointDescriptionEx.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/EndpointDescriptionEx.cs @@ -22,8 +22,7 @@ public static class EndpointDescriptionEx public static bool IsSameAs(this EndpointDescription endpoint, EndpointModel model) { - if (endpoint.SecurityMode != - (model.SecurityMode ?? SecurityMode.SignAndEncrypt).ToStackType()) + if (endpoint.SecurityMode.IsSame(model.SecurityMode ?? SecurityMode.SignAndEncrypt)) { return false; } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/StackTypesEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/StackTypesEx.cs index 6bb43a2acf..b6cb97d964 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/StackTypesEx.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/StackTypesEx.cs @@ -167,21 +167,27 @@ public static UaBrowseDirection ToStackType(this BrowseDirection mode) } /// - /// Convert security mode + /// Match security model /// /// + /// /// - public static UaSecurityMode ToStackType(this SecurityMode mode) + public static bool IsSame(this UaSecurityMode mode, SecurityMode securityMode) { - switch (mode) + switch (securityMode) { + case SecurityMode.Best: + return true; // Any security mode case SecurityMode.Sign: - return UaSecurityMode.Sign; + return mode == UaSecurityMode.Sign; case SecurityMode.SignAndEncrypt: - case SecurityMode.Best: - return UaSecurityMode.SignAndEncrypt; + return mode == UaSecurityMode.SignAndEncrypt; + case SecurityMode.None: + return mode == UaSecurityMode.None; + case SecurityMode.NotNone: + return mode != UaSecurityMode.None; default: - return UaSecurityMode.None; + return false; } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs index 2cf7954f9f..bfa7a31d06 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs @@ -1100,7 +1100,7 @@ private async ValueTask TryConnectAsync(CancellationToken ct) // Get the endpoint by connecting to server's discovery endpoint. // Try to find the first endpoint with security. // - var securityMode = _connection.Endpoint.SecurityMode ?? SecurityMode.Best; + var securityMode = _connection.Endpoint.SecurityMode ?? SecurityMode.NotNone; var securityProfile = _connection.Endpoint.SecurityPolicy; if (_traceMode) { @@ -1130,7 +1130,7 @@ private async ValueTask TryConnectAsync(CancellationToken ct) if (securityMode == SecurityMode.Best && endpointDescription.SecurityMode == MessageSecurityMode.None) { - _logger.LogWarning("Although the use of security was configured, " + + _logger.LogWarning("Although the use of best security was configured, " + "there was no security-enabled endpoint available at url " + "{EndpointUrl}. An endpoint with no security will be used " + "for session {Name} but no credentials will be sent over it.", @@ -1550,8 +1550,7 @@ private void NotifyConnectivityStateChange(EndpointConnectivityState state) var filtered = endpoints .Where(ep => SecurityPolicies.GetDisplayName(ep.SecurityPolicyUri) != null && - (securityMode == SecurityMode.Best || - ep.SecurityMode == securityMode.ToStackType()) && + ep.SecurityMode.IsSame(securityMode) && (securityPolicy == null || string.Equals(ep.SecurityPolicyUri, securityPolicy, StringComparison.OrdinalIgnoreCase))) // diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs index d84f4a7aa2..7134351fb7 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs @@ -411,7 +411,8 @@ public IEnumerable ToWriterGroups(IEnumerable await configService - .PublishNodesAsync(null) -) + .PublishNodesAsync(null)) .Should() .ThrowAsync() - .WithMessage(exceptionResponse) -; + .WithMessage(exceptionResponse); const int numberOfEndpoints = 1; var opcNodes = Enumerable.Range(0, numberOfEndpoints) @@ -227,7 +224,7 @@ await FluentActions await configService.PublishNodesAsync(endpoints[0]); - const string details = "{\"DataSetWriterId\":\"DataSetWriterId0\",\"DataSetWriterGroup\":\"DataSetWriterGroup\",\"OpcNodes\":[{\"Id\":\"nsu=http://microsoft.com/Opc/OpcPlc/;s=SlowUInt0\",\"OpcPublishingIntervalTimespan\":\"00:00:01\"}],\"EndpointUrl\":\"opc.tcp://opcplc:50000\",\"UseSecurity\":false,\"OpcAuthenticationMode\":\"anonymous\"}"; + const string details = "{\"DataSetWriterId\":\"DataSetWriterId0\",\"DataSetWriterGroup\":\"DataSetWriterGroup\",\"OpcNodes\":[{\"Id\":\"nsu=http://microsoft.com/Opc/OpcPlc/;s=SlowUInt0\",\"OpcPublishingIntervalTimespan\":\"00:00:01\"}],\"EndpointUrl\":\"opc.tcp://opcplc:50000\",\"UseSecurity\":null,\"OpcAuthenticationMode\":\"anonymous\"}"; exceptionResponse = "Response 404 Nodes not found: " + details; var opcNodes1 = Enumerable.Range(0, numberOfEndpoints) .Select(i => new OpcNodeModel @@ -242,12 +239,10 @@ await FluentActions // try to unpublish a not published nodes. await FluentActions .Invoking(async () => await configService - .UnpublishNodesAsync(endpointsToDelete[0]) -) + .UnpublishNodesAsync(endpointsToDelete[0])) .Should() .ThrowAsync() - .WithMessage(exceptionResponse) -; + .WithMessage(exceptionResponse); // test for null payload exceptionResponse = "Response 400 : "; @@ -266,12 +261,10 @@ public async Task TestPublishNodesNullOrEmpty() // Check null request. await FluentActions .Invoking(async () => await configService - .PublishNodesAsync(null) -) + .PublishNodesAsync(null)) .Should() .ThrowAsync() - .WithMessage("Response 400 null request is provided") -; + .WithMessage("Response 400 null request is provided"); var request = new PublishedNodesEntryModel { @@ -281,24 +274,20 @@ await FluentActions // Check null OpcNodes in request. await FluentActions .Invoking(async () => await configService - .PublishNodesAsync(request) -) + .PublishNodesAsync(request)) .Should() .ThrowAsync() - .WithMessage("Response 400 null or empty OpcNodes is provided in request") -; + .WithMessage("Response 400 null or empty OpcNodes is provided in request"); request.OpcNodes = new List(); // Check empty OpcNodes in request. await FluentActions .Invoking(async () => await configService - .PublishNodesAsync(request) -) + .PublishNodesAsync(request)) .Should() .ThrowAsync() - .WithMessage("Response 400 null or empty OpcNodes is provided in request") -; + .WithMessage("Response 400 null or empty OpcNodes is provided in request"); } [Fact] @@ -309,12 +298,10 @@ public async Task TestUnpublishNodesNullRequest() // Check null request. await FluentActions .Invoking(async () => await configService - .UnpublishNodesAsync(null) -) + .UnpublishNodesAsync(null)) .Should() .ThrowAsync() - .WithMessage("Response 400 null request is provided") -; + .WithMessage("Response 400 null request is provided"); } [Theory] @@ -352,11 +339,9 @@ public async Task TestUnpublishNodesNullOrEmptyOpcNodes( // Check null or empty OpcNodes in request. await FluentActions .Invoking(async () => await configService - .UnpublishNodesAsync(endpoints[1]) -) + .UnpublishNodesAsync(endpoints[1])) .Should() - .NotThrowAsync() -; + .NotThrowAsync(); var configuredEndpoints = await configService .GetConfiguredEndpointsAsync(); @@ -375,12 +360,10 @@ public async Task TestGetConfiguredNodesOnEndpointNullRequest() // Check call with null. await FluentActions .Invoking(async () => await configService - .GetConfiguredNodesOnEndpointAsync(null) -) + .GetConfiguredNodesOnEndpointAsync(null)) .Should() .ThrowAsync() - .WithMessage("Response 400 null request is provided") -; + .WithMessage("Response 400 null request is provided"); } [Fact] @@ -391,12 +374,10 @@ public async Task TestAddOrUpdateEndpointsNullRequest() // Check call with null. await FluentActions .Invoking(async () => await configService - .AddOrUpdateEndpointsAsync(null) -) + .AddOrUpdateEndpointsAsync(null)) .Should() .ThrowAsync() - .WithMessage("Response 400 null request is provided") -; + .WithMessage("Response 400 null request is provided"); } [Fact] @@ -427,12 +408,41 @@ await FluentActions .AddOrUpdateEndpointsAsync(endpoints)) .Should() .ThrowAsync() - .WithMessage("Response 400 Request contains two entries for the same endpoint at index 0 and 2") -; + .WithMessage("Response 400 Request contains two entries for the same endpoint at index 0 and 2"); } [Fact] - public async Task TestAddOrUpdateEndpointsMultipleEndpointEntriesTimesapn() + public async Task TestAddOrUpdateEndpointsWithNonDefaultWriterGroupTransportAndSecurity() + { + await using var configService = InitPublisherConfigService(); + + const int numberOfEndpoints = 3; + var opcNodes = Enumerable.Range(0, numberOfEndpoints) + .Select(i => new OpcNodeModel + { + Id = $"nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt{i}" + }) + .ToList(); + + var endpoints = Enumerable.Range(0, numberOfEndpoints) + .Select(i => GenerateEndpoint(i, opcNodes, false)) + .ToList(); + + endpoints.ForEach(ep => ep.WriterGroupTransport = WriterGroupTransport.FileSystem); + endpoints.ForEach(ep => ep.EndpointSecurityMode = SecurityMode.Best); + + await configService.AddOrUpdateEndpointsAsync(endpoints); + + var configuredEndpoints = await configService.GetConfiguredEndpointsAsync(); + Assert.All(configuredEndpoints, ep => + { + Assert.Equal(WriterGroupTransport.FileSystem, ep.WriterGroupTransport); + Assert.Equal(SecurityMode.Best, ep.EndpointSecurityMode); + }); + } + + [Fact] + public async Task TestAddOrUpdateEndpointsMultipleEndpointEntriesTimespan() { await using var configService = InitPublisherConfigService(); @@ -463,8 +473,7 @@ await FluentActions .AddOrUpdateEndpointsAsync(endpoints)) .Should() .ThrowAsync() - .WithMessage("Response 400 Request contains two entries for the same endpoint at index 0 and 2") -; + .WithMessage("Response 400 Request contains two entries for the same endpoint at index 0 and 2"); } [Theory] @@ -505,8 +514,7 @@ bool useDataSetSpecificEndpoints for (var i = 0; i < numberOfEndpoints; i++) { var endpointNodes = await configService - .GetConfiguredNodesOnEndpointAsync(endpoints[i]) -; + .GetConfiguredNodesOnEndpointAsync(endpoints[i]); AssertSameNodes(endpoints[i], endpointNodes); } @@ -546,28 +554,23 @@ public async Task TestAddOrUpdateEndpointsRemoveEndpoints(string publishedNodesF { await FluentActions .Invoking(async () => await configService - .AddOrUpdateEndpointsAsync(new List { request }) -) + .AddOrUpdateEndpointsAsync(new List { request })) .Should() .ThrowAsync() - .WithMessage($"Response 404 Endpoint not found: {request.EndpointUrl}") -; + .WithMessage($"Response 404 Endpoint not found: {request.EndpointUrl}"); } else { await FluentActions .Invoking(async () => await configService - .AddOrUpdateEndpointsAsync(new List { request }) -) + .AddOrUpdateEndpointsAsync(new List { request })) .Should() - .NotThrowAsync() -; + .NotThrowAsync(); } } var configuredEndpoints = await configService - .GetConfiguredEndpointsAsync() -; + .GetConfiguredEndpointsAsync(); Assert.Empty(configuredEndpoints); } @@ -602,8 +605,7 @@ public async Task TestAddOrUpdateEndpointsAddAndRemove() for (var i = 0; i < 3; i++) { var endpointNodes = await configService - .GetConfiguredNodesOnEndpointAsync(endpoints[i]) -; + .GetConfiguredNodesOnEndpointAsync(endpoints[i]); AssertSameNodes(endpoints[i], endpointNodes); } @@ -616,19 +618,16 @@ PublishedNodesEntryModel endpoint { await FluentActions .Invoking(async () => await publisherConfigurationService - .GetConfiguredNodesOnEndpointAsync(endpoint) -) + .GetConfiguredNodesOnEndpointAsync(endpoint)) .Should() .ThrowAsync() - .WithMessage($"Response 404 Endpoint not found: {endpoint.EndpointUrl}") -; + .WithMessage($"Response 404 Endpoint not found: {endpoint.EndpointUrl}"); } // Those calls should throw. for (var i = 3; i < 5; i++) { - await AssertGetConfiguredNodesOnEndpointThrows(configService, endpoints[i]) -; + await AssertGetConfiguredNodesOnEndpointThrows(configService, endpoints[i]); } var updateRequest = Enumerable.Range(0, 5) @@ -642,28 +641,23 @@ await AssertGetConfiguredNodesOnEndpointThrows(configService, endpoints[i]) // Should throw as updateRequest[3] endpoint is not present in current configuratoin. await FluentActions .Invoking(async () => await configService - .AddOrUpdateEndpointsAsync(updateRequest) -) + .AddOrUpdateEndpointsAsync(updateRequest)) .Should() .ThrowAsync() - .WithMessage($"Response 404 Endpoint not found: {updateRequest[3].EndpointUrl}") -; + .WithMessage($"Response 404 Endpoint not found: {updateRequest[3].EndpointUrl}"); updateRequest.RemoveAt(3); await configService.AddOrUpdateEndpointsAsync(updateRequest); // Check endpoint 0. - await AssertGetConfiguredNodesOnEndpointThrows(configService, endpoints[0]) -; + await AssertGetConfiguredNodesOnEndpointThrows(configService, endpoints[0]); // Check endpoint 1. - await AssertGetConfiguredNodesOnEndpointThrows(configService, endpoints[1]) -; + await AssertGetConfiguredNodesOnEndpointThrows(configService, endpoints[1]); // Check endpoint 2. var endpointNodes2 = await configService - .GetConfiguredNodesOnEndpointAsync(endpoints[2]) -; + .GetConfiguredNodesOnEndpointAsync(endpoints[2]); AssertSameNodes(updateRequest[2], endpointNodes2); @@ -673,8 +667,7 @@ await AssertGetConfiguredNodesOnEndpointThrows(configService, endpoints[3]) // Check endpoint 4. var endpointNodes4 = await configService - .GetConfiguredNodesOnEndpointAsync(endpoints[4]) -; + .GetConfiguredNodesOnEndpointAsync(endpoints[4]); AssertSameNodes(updateRequest[3], endpointNodes4); } @@ -691,8 +684,7 @@ public async Task TestInitStandaloneJobOrchestratorFromEmptyOpcNodes(string publ // Engine/empty_opc_nodes.json contains entries with null or empty OpcNodes. // Those entries should not result in any endpoint entries in publisherConfigurationService. var configuredEndpoints = await configService - .GetConfiguredEndpointsAsync() -; + .GetConfiguredEndpointsAsync(); Assert.Empty(configuredEndpoints); // There should also not be any job entries. @@ -784,8 +776,7 @@ public async Task PublishNodesOnEmptyConfiguration(string publishedNodesFile) await FluentActions .Invoking(async () => await configService.PublishNodesAsync(request)) .Should() - .NotThrowAsync() -; + .NotThrowAsync(); } _publisher.WriterGroups.Count @@ -810,8 +801,7 @@ public async Task PublishNodesOnExistingConfiguration(string existingConfig, str await FluentActions .Invoking(async () => await configService.PublishNodesAsync(request)) .Should() - .NotThrowAsync() -; + .NotThrowAsync(); } _publisher.WriterGroups.Count @@ -835,8 +825,7 @@ public async Task PublishNodesOnNewConfiguration(string existingConfig, string n await FluentActions .Invoking(async () => await configService.PublishNodesAsync(request)) .Should() - .NotThrowAsync() -; + .NotThrowAsync(); } _publisher.WriterGroups.Count @@ -860,8 +849,7 @@ public async Task UnpublishNodesOnExistingConfiguration(string publishedNodesFil await FluentActions .Invoking(async () => await configService.UnpublishNodesAsync(request)) .Should() - .NotThrowAsync() -; + .NotThrowAsync(); } _publisher.WriterGroups.Count @@ -886,8 +874,7 @@ await FluentActions .Invoking(async () => await configService.UnpublishNodesAsync(request)) .Should() .ThrowAsync() - .WithMessage($"Response 404 Endpoint not found: {request.EndpointUrl}") -; + .WithMessage($"Response 404 Endpoint not found: {request.EndpointUrl}"); } _publisher.WriterGroups.Sum(writerGroup => writerGroup.DataSetWriters.Count) @@ -931,8 +918,7 @@ public async Task PublishNodesStressTest(int numberOfEndpoints, int numberOfNode await FluentActions .Invoking(async () => await configService.PublishNodesAsync(request)) .Should() - .NotThrowAsync() -; + .NotThrowAsync(); } void CheckEndpointsAndNodes( @@ -973,8 +959,7 @@ int expectedNumberOfNodesPerEndpoint await FluentActions .Invoking(async () => await configService.PublishNodesAsync(request)) .Should() - .NotThrowAsync() -; + .NotThrowAsync(); } // Check @@ -986,8 +971,7 @@ await FluentActions await FluentActions .Invoking(async () => await configService.UnpublishNodesAsync(request)) .Should() - .NotThrowAsync() -; + .NotThrowAsync(); } // Check diff --git a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/EndpointModelEx.cs b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/EndpointModelEx.cs index 4827dd3c2d..5b2c30cf4c 100644 --- a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/EndpointModelEx.cs +++ b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/EndpointModelEx.cs @@ -66,8 +66,8 @@ public static bool HasSameSecurityProperties(this EndpointModel? model, Endpoint { return false; } - if ((that.SecurityMode ?? SecurityMode.Best) != - (model.SecurityMode ?? SecurityMode.Best)) + if ((that.SecurityMode ?? SecurityMode.NotNone) != + (model.SecurityMode ?? SecurityMode.NotNone)) { return false; } @@ -91,7 +91,7 @@ public static int CreateConsistentHash(this EndpointModel endpoint) endpoint.SecurityPolicy ?? string.Empty); hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode( - endpoint.SecurityMode ?? SecurityMode.Best); + endpoint.SecurityMode ?? SecurityMode.NotNone); return hashCode; } diff --git a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs index cf710cfa92..52387e0770 100644 --- a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs +++ b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs @@ -125,11 +125,17 @@ public static bool HasSameWriterGroup(this PublishedNodesEntryModel model, { return null; } + + var useSecurity = + model.Endpoint?.SecurityMode == SecurityMode.None ? false : + model.Endpoint?.SecurityMode == SecurityMode.NotNone ? true : + (bool?)null; + return new PublishedNodesEntryModel { EndpointUrl = model.Endpoint?.Url, - UseSecurity = model.Endpoint?.SecurityMode != SecurityMode.None, - EndpointSecurityMode = model.Endpoint?.SecurityMode, + UseSecurity = useSecurity, + EndpointSecurityMode = !useSecurity.HasValue ? model.Endpoint?.SecurityMode : null, EndpointSecurityPolicy = model.Endpoint?.SecurityPolicy, OpcAuthenticationMode = ToAuthenticationModel(model.User?.Type), OpcAuthenticationPassword = model.User.GetPassword(), @@ -213,7 +219,7 @@ public static string GetUniqueDataSetWriterId(this PublishedNodesEntryModel mode id.AppendLine(); } var securityMode = model.EndpointSecurityMode ?? - (model.UseSecurity ? SecurityMode.Best : SecurityMode.None); + ((model.UseSecurity ?? false) ? SecurityMode.NotNone : SecurityMode.None); if (securityMode != SecurityMode.None) { id.Append(securityMode); @@ -326,14 +332,14 @@ public static bool HasSameDataSet(this PublishedNodesEntryModel model, { return false; } - if (model.UseSecurity != that.UseSecurity) + if ((model.UseSecurity ?? false) != (that.UseSecurity ?? false)) { return false; } if ((model.EndpointSecurityMode ?? - (model.UseSecurity ? SecurityMode.Best : SecurityMode.None)) != + ((model.UseSecurity ?? false) ? SecurityMode.NotNone : SecurityMode.None)) != (that.EndpointSecurityMode ?? - (that.UseSecurity ? SecurityMode.Best : SecurityMode.None))) + ((that.UseSecurity ?? false) ? SecurityMode.NotNone : SecurityMode.None))) { return false; } diff --git a/src/Azure.IIoT.OpcUa/tests/Publisher/Models/PublishedNodesEntryModelTests.cs b/src/Azure.IIoT.OpcUa/tests/Publisher/Models/PublishedNodesEntryModelTests.cs index ce73a4cc06..ff3255c5d2 100644 --- a/src/Azure.IIoT.OpcUa/tests/Publisher/Models/PublishedNodesEntryModelTests.cs +++ b/src/Azure.IIoT.OpcUa/tests/Publisher/Models/PublishedNodesEntryModelTests.cs @@ -31,7 +31,7 @@ public void UseSecurityDeserializationTest() """; var model = newtonSoftJsonSerializer.Deserialize(modelJson); - Assert.False(model.UseSecurity); + Assert.Null(model.UseSecurity); modelJson = """ @@ -72,22 +72,26 @@ public void UseSecuritySerializationTest() var model = new PublishedNodesEntryModel { EndpointUrl = "opc.tcp://localhost:50000", - OpcNodes = new List { - new() { + OpcNodes = new List + { + new() + { Id = "i=2258" } } }; var modeJson = newtonSoftJsonSerializer.SerializeToString(model); - Assert.Contains("\"UseSecurity\":false", modeJson, StringComparison.Ordinal); + Assert.DoesNotContain("\"UseSecurity\":false", modeJson, StringComparison.Ordinal); model = new PublishedNodesEntryModel { EndpointUrl = "opc.tcp://localhost:50000", UseSecurity = false, - OpcNodes = new List { - new() { + OpcNodes = new List + { + new() + { Id = "i=2258" } } @@ -100,8 +104,10 @@ public void UseSecuritySerializationTest() { EndpointUrl = "opc.tcp://localhost:50000", UseSecurity = true, - OpcNodes = new List { - new() { + OpcNodes = new List + { + new() + { Id = "i=2258" } }