mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 16:52:07 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1092 lines
50 KiB
C#
1092 lines
50 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Security.Certificates;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Unit tests for <see cref="RoleManager"/> covering OPC UA Part 18 §4
|
|
/// compliance: well-known roles registration, default identities, reserved-
|
|
/// role immutability, identity criteria validation, exclude semantics for
|
|
/// applications and endpoints, default-field-aware endpoint comparison,
|
|
/// CustomConfiguration handling, RaiseConfigurationChanged events, and
|
|
/// spec-correct status codes for every mutator.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Roles")]
|
|
[Parallelizable]
|
|
public class RoleManagerTests
|
|
{
|
|
private static IdentityMappingRuleType AuthenticatedUser()
|
|
{
|
|
return new() { CriteriaType = IdentityCriteriaType.AuthenticatedUser };
|
|
}
|
|
|
|
private static IdentityMappingRuleType UserName(string name)
|
|
{
|
|
return new() { CriteriaType = IdentityCriteriaType.UserName, Criteria = name };
|
|
}
|
|
|
|
private static IdentityMappingRuleType Thumbprint(string thumbprint)
|
|
{
|
|
return new() { CriteriaType = IdentityCriteriaType.Thumbprint, Criteria = thumbprint };
|
|
}
|
|
|
|
private static IdentityMappingRuleType X509Subject(string subject)
|
|
{
|
|
return new() { CriteriaType = IdentityCriteriaType.X509Subject, Criteria = subject };
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Well-known roles + default identities (Part 18 §4.3)
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void Constructor_RegistersAllNineWellKnownRoles()
|
|
{
|
|
using var manager = new RoleManager();
|
|
IReadOnlyList<NodeId> roleIds = manager.RoleIds;
|
|
|
|
Assert.That(roleIds, Has.Member(ObjectIds.WellKnownRole_Anonymous));
|
|
Assert.That(roleIds, Has.Member(ObjectIds.WellKnownRole_AuthenticatedUser));
|
|
Assert.That(roleIds, Has.Member(ObjectIds.WellKnownRole_TrustedApplication));
|
|
Assert.That(roleIds, Has.Member(ObjectIds.WellKnownRole_Observer));
|
|
Assert.That(roleIds, Has.Member(ObjectIds.WellKnownRole_Operator));
|
|
Assert.That(roleIds, Has.Member(ObjectIds.WellKnownRole_Engineer));
|
|
Assert.That(roleIds, Has.Member(ObjectIds.WellKnownRole_Supervisor));
|
|
Assert.That(roleIds, Has.Member(ObjectIds.WellKnownRole_ConfigureAdmin));
|
|
Assert.That(roleIds, Has.Member(ObjectIds.WellKnownRole_SecurityAdmin));
|
|
Assert.That(roleIds, Has.Count.EqualTo(9));
|
|
}
|
|
|
|
[Test]
|
|
public void Constructor_PrePopulatesAnonymousRoleWithSpecMandatedIdentities()
|
|
{
|
|
using var manager = new RoleManager();
|
|
RoleEntry? entry = manager.GetRole(ObjectIds.WellKnownRole_Anonymous);
|
|
Assert.That(entry, Is.Not.Null);
|
|
Assert.That(entry!.Identities, Has.Count.EqualTo(2));
|
|
Assert.That(entry.Identities.Any(r => r.CriteriaType == IdentityCriteriaType.Anonymous), Is.True);
|
|
Assert.That(entry.Identities.Any(r => r.CriteriaType == IdentityCriteriaType.AuthenticatedUser), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void Constructor_PrePopulatesAuthenticatedUserRoleWithSpecMandatedIdentity()
|
|
{
|
|
using var manager = new RoleManager();
|
|
RoleEntry? entry = manager.GetRole(ObjectIds.WellKnownRole_AuthenticatedUser);
|
|
Assert.That(entry, Is.Not.Null);
|
|
Assert.That(entry!.Identities, Has.Count.EqualTo(1));
|
|
Assert.That(entry.Identities[0].CriteriaType, Is.EqualTo(IdentityCriteriaType.AuthenticatedUser));
|
|
}
|
|
|
|
[Test]
|
|
public void Constructor_PrePopulatesTrustedApplicationRoleWithSpecMandatedIdentity()
|
|
{
|
|
using var manager = new RoleManager();
|
|
RoleEntry? entry = manager.GetRole(ObjectIds.WellKnownRole_TrustedApplication);
|
|
Assert.That(entry, Is.Not.Null);
|
|
Assert.That(entry!.Identities, Has.Count.EqualTo(1));
|
|
Assert.That(entry.Identities[0].CriteriaType, Is.EqualTo(IdentityCriteriaType.TrustedApplication));
|
|
}
|
|
|
|
[Test]
|
|
public void Constructor_ConfigurableWellKnownRolesHaveNoDefaultIdentities()
|
|
{
|
|
using var manager = new RoleManager();
|
|
foreach (NodeId roleId in new[]
|
|
{
|
|
ObjectIds.WellKnownRole_Observer,
|
|
ObjectIds.WellKnownRole_Operator,
|
|
ObjectIds.WellKnownRole_Engineer,
|
|
ObjectIds.WellKnownRole_Supervisor,
|
|
ObjectIds.WellKnownRole_ConfigureAdmin,
|
|
ObjectIds.WellKnownRole_SecurityAdmin
|
|
})
|
|
{
|
|
RoleEntry? entry = manager.GetRole(roleId);
|
|
Assert.That(entry, Is.Not.Null);
|
|
Assert.That(entry!.Identities, Is.Empty, $"{entry.BrowseName} should have no default identities.");
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Reserved-role immutability (Part 18 §4.3)
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void AddIdentity_OnAnonymousRole_ReturnsBadRequestNotAllowed()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Anonymous, UserName("x"));
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadRequestNotAllowed));
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_OnAuthenticatedUserRole_ReturnsBadRequestNotAllowed()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_AuthenticatedUser, UserName("x"));
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadRequestNotAllowed));
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_OnTrustedApplicationRole_ReturnsBadRequestNotAllowed()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_TrustedApplication, UserName("x"));
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadRequestNotAllowed));
|
|
}
|
|
|
|
[Test]
|
|
public void RemoveRole_OnReservedRole_ReturnsBadRequestNotAllowed()
|
|
{
|
|
using var manager = new RoleManager();
|
|
foreach (NodeId reserved in new[]
|
|
{
|
|
ObjectIds.WellKnownRole_Anonymous,
|
|
ObjectIds.WellKnownRole_AuthenticatedUser,
|
|
ObjectIds.WellKnownRole_TrustedApplication
|
|
})
|
|
{
|
|
ServiceResult result = manager.RemoveRole(reserved);
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadRequestNotAllowed),
|
|
$"RemoveRole on reserved role {reserved} should fail.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void RemoveRole_OnConfigurableWellKnownRole_Succeeds()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.RemoveRole(ObjectIds.WellKnownRole_Observer);
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
Assert.That(manager.GetRole(ObjectIds.WellKnownRole_Observer), Is.Null);
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Status codes (Part 18 §4.4.5-§4.4.10)
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void AddIdentity_DuplicateRule_ReturnsBadAlreadyExists()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Observer, UserName("alice"))), Is.True);
|
|
ServiceResult duplicate = manager.AddIdentity(
|
|
ObjectIds.WellKnownRole_Observer, UserName("alice"));
|
|
Assert.That(duplicate.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadAlreadyExists));
|
|
}
|
|
|
|
[Test]
|
|
public void RemoveIdentity_NotPresent_ReturnsBadNotFound()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.RemoveIdentity(
|
|
ObjectIds.WellKnownRole_Observer, UserName("alice"));
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNotFound));
|
|
}
|
|
|
|
[Test]
|
|
public void AddApplication_DuplicateUri_ReturnsBadAlreadyExists()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddApplication(ObjectIds.WellKnownRole_Observer, "urn:app")), Is.True);
|
|
ServiceResult duplicate = manager.AddApplication(
|
|
ObjectIds.WellKnownRole_Observer, "urn:app");
|
|
Assert.That(duplicate.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadAlreadyExists));
|
|
}
|
|
|
|
[Test]
|
|
public void AddEndpoint_DuplicateEndpoint_ReturnsBadAlreadyExists()
|
|
{
|
|
using var manager = new RoleManager();
|
|
var ep = new EndpointType { EndpointUrl = "opc.tcp://srv:4840" };
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddEndpoint(ObjectIds.WellKnownRole_Observer, ep)), Is.True);
|
|
ServiceResult duplicate = manager.AddEndpoint(ObjectIds.WellKnownRole_Observer, ep);
|
|
Assert.That(duplicate.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadAlreadyExists));
|
|
}
|
|
|
|
[Test]
|
|
public void AddEndpointAcceptsEverySingleFieldConstraint()
|
|
{
|
|
using var manager = new RoleManager();
|
|
EndpointType[] endpoints =
|
|
[
|
|
new() { EndpointUrl = "opc.tcp://srv:4840" },
|
|
new() { SecurityMode = MessageSecurityMode.SignAndEncrypt },
|
|
new() { SecurityPolicyUri = SecurityPolicies.Basic256Sha256 },
|
|
new() { TransportProfileUri = "urn:test:transport-profile" }
|
|
];
|
|
|
|
foreach (EndpointType endpoint in endpoints)
|
|
{
|
|
Assert.That(
|
|
ServiceResult.IsGood(
|
|
manager.AddEndpoint(ObjectIds.WellKnownRole_Observer, endpoint)),
|
|
Is.True);
|
|
}
|
|
|
|
RoleEntry? entry = manager.GetRole(ObjectIds.WellKnownRole_Observer);
|
|
Assert.That(entry, Is.Not.Null);
|
|
Assert.That(entry!.Endpoints, Has.Count.EqualTo(endpoints.Length));
|
|
}
|
|
|
|
[Test]
|
|
public void AddEndpointSecurityModeOnlyRuleSupportsDuplicateDetectionAndRemoval()
|
|
{
|
|
using var manager = new RoleManager();
|
|
var endpoint = new EndpointType
|
|
{
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt
|
|
};
|
|
|
|
ServiceResult add = manager.AddEndpoint(
|
|
ObjectIds.WellKnownRole_Observer,
|
|
endpoint);
|
|
Assert.That(ServiceResult.IsGood(add), Is.True);
|
|
|
|
RoleEntry? entry = manager.GetRole(ObjectIds.WellKnownRole_Observer);
|
|
Assert.That(entry, Is.Not.Null);
|
|
Assert.That(entry!.Endpoints, Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
entry.Endpoints[0].SecurityMode,
|
|
Is.EqualTo(MessageSecurityMode.SignAndEncrypt));
|
|
Assert.That(string.IsNullOrEmpty(entry.Endpoints[0].EndpointUrl), Is.True);
|
|
|
|
ServiceResult duplicate = manager.AddEndpoint(
|
|
ObjectIds.WellKnownRole_Observer,
|
|
endpoint);
|
|
Assert.That(duplicate.StatusCode, Is.EqualTo(StatusCodes.BadAlreadyExists));
|
|
|
|
ServiceResult remove = manager.RemoveEndpoint(
|
|
ObjectIds.WellKnownRole_Observer,
|
|
endpoint);
|
|
Assert.That(ServiceResult.IsGood(remove), Is.True);
|
|
Assert.That(
|
|
manager.GetRole(ObjectIds.WellKnownRole_Observer)!.Endpoints,
|
|
Is.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public void AddEndpointNullOrAllDefaultRuleReturnsBadInvalidArgument()
|
|
{
|
|
using var manager = new RoleManager();
|
|
|
|
ServiceResult nullEndpoint = manager.AddEndpoint(
|
|
ObjectIds.WellKnownRole_Observer,
|
|
null!);
|
|
ServiceResult defaultEndpoint = manager.AddEndpoint(
|
|
ObjectIds.WellKnownRole_Observer,
|
|
new EndpointType());
|
|
|
|
Assert.That(nullEndpoint.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
Assert.That(defaultEndpoint.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
|
|
[Test]
|
|
public void RemoveRole_OnUnknownRole_ReturnsBadNodeIdUnknown()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.RemoveRole(new NodeId(99999u));
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_OnUnknownRole_ReturnsBadNodeIdUnknown()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(new NodeId(99999u), UserName("x"));
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Identity criteria validation (Part 18 §4.4.3)
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void AddIdentity_AnonymousWithNonEmptyCriteria_ReturnsBadInvalidArgument()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
new IdentityMappingRuleType
|
|
{
|
|
CriteriaType = IdentityCriteriaType.Anonymous,
|
|
Criteria = "not-empty"
|
|
});
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_ThumbprintWithLowerCase_ReturnsBadInvalidArgument()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
Thumbprint("aabbccddee"));
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_ThumbprintWithSpaces_ReturnsBadInvalidArgument()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
Thumbprint("AA BB CC"));
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_ThumbprintValidUpperHex_Succeeds()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
Thumbprint("AABBCCDDEE0011"));
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_X509SubjectInvalidFormat_ReturnsBadInvalidArgument()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
X509Subject("CN=NoQuotes"));
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_X509SubjectValidFormat_Succeeds()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
X509Subject("CN=\"User Name\"/O=\"Company\""));
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// ResolveGrantedRoles
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_AnonymousIdentity_GrantsAnonymousRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
var identity = new UserIdentity();
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity, clientCertificate: null, endpoint: null);
|
|
Assert.That(roles, Has.Member(ObjectIds.WellKnownRole_Anonymous));
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_AnonymousIdentity_DoesNotGrantAuthenticatedUserRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
var identity = new UserIdentity();
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity, null, null);
|
|
Assert.That(roles, Has.No.Member(ObjectIds.WellKnownRole_AuthenticatedUser));
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_AnonymousIdentity_DoesNotGrantTrustedApplicationWithoutSignedChannel()
|
|
{
|
|
using var manager = new RoleManager();
|
|
var identity = new UserIdentity();
|
|
using Certificate cert = CertificateBuilder.Create("CN=Test")
|
|
.SetRSAKeySize(CertificateFactory.DefaultKeySize).CreateForRSA();
|
|
// No endpoint = no signed channel ⇒ TrustedApplication rule not satisfied.
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity, cert, null);
|
|
Assert.That(roles, Has.No.Member(ObjectIds.WellKnownRole_TrustedApplication));
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_AnonymousIdentityWithCertAndSignedChannel_GrantsTrustedApplication()
|
|
{
|
|
using var manager = new RoleManager();
|
|
var identity = new UserIdentity();
|
|
using Certificate cert = CertificateBuilder.Create("CN=Test")
|
|
.SetRSAKeySize(CertificateFactory.DefaultKeySize).CreateForRSA();
|
|
var endpoint = new EndpointDescription { SecurityMode = MessageSecurityMode.Sign };
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity, cert, endpoint);
|
|
Assert.That(roles, Has.Member(ObjectIds.WellKnownRole_TrustedApplication));
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_UserNameMatchesIdentityRule_GrantsRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator, UserName("operator1"))), Is.True);
|
|
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("operator1");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, null, null);
|
|
Assert.That(roles, Has.Member(ObjectIds.WellKnownRole_Operator));
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_RoleWithEmptyIdentitiesAndCustomConfigFalse_NotGranted()
|
|
{
|
|
using var manager = new RoleManager();
|
|
// Engineer starts empty; CustomConfiguration defaults to false.
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("alice");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, null, null);
|
|
Assert.That(roles, Has.No.Member(ObjectIds.WellKnownRole_Engineer));
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_RoleWithEmptyIdentitiesAndCustomConfigTrue_Granted()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.SetCustomConfiguration(ObjectIds.WellKnownRole_Engineer, true)), Is.True);
|
|
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("alice");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, null, null);
|
|
Assert.That(roles, Has.Member(ObjectIds.WellKnownRole_Engineer));
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// AddRole
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void AddRole_NewName_AllocatesDynamicNodeIdInTheDefaultNamespace()
|
|
{
|
|
using var manager = new RoleManager();
|
|
var namespaces = new NamespaceTable();
|
|
namespaces.GetIndexOrAppend("http://example.org/custom");
|
|
namespaces.GetIndexOrAppend("http://example.org/roles");
|
|
|
|
ServiceResult result = manager.AddRole("CustomRole",
|
|
"http://example.org/custom", namespaces, defaultNamespaceIndex: 2,
|
|
out NodeId newId);
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
Assert.That(newId.IsNull, Is.False);
|
|
|
|
// Part 18 §4.2.2: NamespaceUri qualifies the BrowseName of the new
|
|
// role; the NodeId is server-assigned and must stay in the namespace
|
|
// the server picked, never in the caller-supplied one.
|
|
Assert.That(newId.NamespaceIndex, Is.EqualTo(2),
|
|
"The role NodeId must be allocated in the default namespace.");
|
|
|
|
RoleEntry? entry = manager.GetRole(newId);
|
|
Assert.That(entry, Is.Not.Null);
|
|
Assert.That(entry!.NamespaceUri, Is.EqualTo("http://example.org/custom"),
|
|
"The BrowseName must stay qualified by the requested NamespaceUri.");
|
|
}
|
|
|
|
[Test]
|
|
public void AddRole_UnregisteredNamespaceUri_ReturnsBadInvalidArgument()
|
|
{
|
|
using var manager = new RoleManager();
|
|
|
|
ServiceResult result = manager.AddRole("CustomRole",
|
|
"http://example.org/not-registered", new NamespaceTable(),
|
|
defaultNamespaceIndex: 1, out NodeId newId);
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
Assert.That(newId.IsNull, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void AddRole_SkipsNodeIdsReportedInUseByTheAddressSpace()
|
|
{
|
|
using var manager = new RoleManager();
|
|
var taken = new HashSet<NodeId>
|
|
{
|
|
new(1u, 2),
|
|
new(2u, 2),
|
|
new(3u, 2)
|
|
};
|
|
manager.NodeIdInUseProbe = taken.Contains;
|
|
|
|
var namespaces = new NamespaceTable();
|
|
namespaces.GetIndexOrAppend("http://example.org/custom");
|
|
namespaces.GetIndexOrAppend("http://example.org/roles");
|
|
|
|
Assert.That(ServiceResult.IsGood(manager.AddRole("CustomRole", null,
|
|
namespaces, defaultNamespaceIndex: 2, out NodeId first)), Is.True);
|
|
Assert.That(first, Is.EqualTo(new NodeId(4u, 2)),
|
|
"Identifiers already used in the address space must be skipped.");
|
|
|
|
Assert.That(ServiceResult.IsGood(manager.AddRole("OtherRole", null,
|
|
namespaces, defaultNamespaceIndex: 2, out NodeId second)), Is.True);
|
|
Assert.That(second, Is.EqualTo(new NodeId(5u, 2)));
|
|
}
|
|
|
|
[Test]
|
|
public void AddRole_NeverReusesTheNodeIdOfAnExistingRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
var namespaces = new NamespaceTable();
|
|
namespaces.GetIndexOrAppend("http://example.org/roles");
|
|
|
|
var allocated = new HashSet<NodeId>();
|
|
for (int ii = 0; ii < 5; ii++)
|
|
{
|
|
Assert.That(ServiceResult.IsGood(manager.AddRole($"Role{ii}", null,
|
|
namespaces, defaultNamespaceIndex: 1, out NodeId roleId)), Is.True);
|
|
Assert.That(allocated.Add(roleId), Is.True,
|
|
"AddRole must never hand out a NodeId that is already a role.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void AddRole_WellKnownNameUnderOpcUaNamespace_ReusesWellKnownNodeId()
|
|
{
|
|
using var manager = new RoleManager();
|
|
// Remove existing well-known role so we can re-add it via AddRole.
|
|
Assert.That(ServiceResult.IsGood(manager.RemoveRole(ObjectIds.WellKnownRole_Observer)), Is.True);
|
|
|
|
var namespaces = new NamespaceTable();
|
|
ServiceResult result = manager.AddRole(
|
|
BrowseNames.WellKnownRole_Observer,
|
|
Ua.Namespaces.OpcUa,
|
|
namespaces,
|
|
defaultNamespaceIndex: 0,
|
|
out NodeId newId);
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
Assert.That(newId, Is.EqualTo(ObjectIds.WellKnownRole_Observer));
|
|
}
|
|
|
|
[Test]
|
|
public void AddRole_DuplicateName_ReturnsBadAlreadyExists()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddRole(
|
|
BrowseNames.WellKnownRole_Observer,
|
|
Ua.Namespaces.OpcUa,
|
|
new NamespaceTable(),
|
|
defaultNamespaceIndex: 0,
|
|
out _);
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadAlreadyExists));
|
|
}
|
|
|
|
[Test]
|
|
public void AddRole_EmptyName_ReturnsBadInvalidArgument()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddRole(string.Empty, null, new NamespaceTable(), 0, out _);
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// RoleConfigurationChanged event
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void AddIdentity_OnSuccess_RaisesRoleConfigurationChangedEvent()
|
|
{
|
|
using var manager = new RoleManager();
|
|
RoleConfigurationChangedEventArgs? captured = null;
|
|
manager.RoleConfigurationChanged += (_, e) => captured = e;
|
|
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Observer, UserName("a"))), Is.True);
|
|
Assert.That(captured, Is.Not.Null);
|
|
Assert.That(captured!.Kind, Is.EqualTo(RoleConfigurationChangeKind.IdentityAdded));
|
|
Assert.That(captured.RoleId, Is.EqualTo(ObjectIds.WellKnownRole_Observer));
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_OnFailure_DoesNotRaiseEvent()
|
|
{
|
|
using var manager = new RoleManager();
|
|
bool raised = false;
|
|
manager.RoleConfigurationChanged += (_, _) => raised = true;
|
|
|
|
// Anonymous role is reserved → AddIdentity fails.
|
|
Assert.That(ServiceResult.IsBad(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Anonymous, UserName("a"))), Is.True);
|
|
Assert.That(raised, Is.False);
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Application / Endpoint exclude semantics (Part 18 §4.4.1)
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_ApplicationFilterInclude_RestrictsToListedApplications()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator, AuthenticatedUser())), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddApplication(ObjectIds.WellKnownRole_Operator, "urn:listed:app")), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.SetApplicationsExclude(ObjectIds.WellKnownRole_Operator, false)), Is.True);
|
|
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("op");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
// No cert ⇒ client app URI is empty ⇒ doesn't match the include list
|
|
// ⇒ role not granted.
|
|
var endpoint = new EndpointDescription { SecurityMode = MessageSecurityMode.Sign };
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, clientCertificate: null, endpoint);
|
|
Assert.That(roles, Has.No.Member(ObjectIds.WellKnownRole_Operator),
|
|
"ApplicationUri does not match the include list — role must not be granted.");
|
|
}
|
|
|
|
[Test]
|
|
public void EndpointTypeComparer_DefaultFieldsAreWildcards()
|
|
{
|
|
// Rule with no SecurityMode/PolicyUri/TransportProfileUri should
|
|
// match any candidate that has the matching URL — those default
|
|
// fields act as wildcards per §4.4.2.
|
|
var rule = new EndpointType { EndpointUrl = "opc.tcp://srv:4840" };
|
|
var candidate = new EndpointType
|
|
{
|
|
EndpointUrl = "opc.tcp://srv:4840",
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
|
SecurityPolicyUri = "http://example/Policy",
|
|
TransportProfileUri = "http://example/Profile"
|
|
};
|
|
Assert.That(EndpointTypeComparer.Matches(rule, candidate), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRolesSecurityModeOnlyEndpointRuleMatchesAcrossUrls()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(
|
|
ServiceResult.IsGood(
|
|
manager.AddIdentity(
|
|
ObjectIds.WellKnownRole_Operator,
|
|
AuthenticatedUser())),
|
|
Is.True);
|
|
Assert.That(
|
|
ServiceResult.IsGood(
|
|
manager.AddEndpoint(
|
|
ObjectIds.WellKnownRole_Operator,
|
|
new EndpointType
|
|
{
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt
|
|
})),
|
|
Is.True);
|
|
Assert.That(
|
|
ServiceResult.IsGood(
|
|
manager.SetEndpointsExclude(
|
|
ObjectIds.WellKnownRole_Operator,
|
|
false)),
|
|
Is.True);
|
|
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("operator");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
IList<NodeId> matchingRoles = manager.ResolveGrantedRoles(
|
|
identity.Object,
|
|
clientCertificate: null,
|
|
new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://first-host:4840",
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt
|
|
});
|
|
IList<NodeId> nonMatchingRoles = manager.ResolveGrantedRoles(
|
|
identity.Object,
|
|
clientCertificate: null,
|
|
new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://second-host:4840",
|
|
SecurityMode = MessageSecurityMode.Sign
|
|
});
|
|
|
|
Assert.That(
|
|
matchingRoles,
|
|
Has.Member(ObjectIds.WellKnownRole_Operator));
|
|
Assert.That(
|
|
nonMatchingRoles,
|
|
Has.No.Member(ObjectIds.WellKnownRole_Operator));
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Gap 2: UserName rule case-sensitivity
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_UserNameRuleDifferentCase_DoesNotGrantRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator, UserName("operator1"))), Is.True);
|
|
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("Operator1");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, null, null);
|
|
Assert.That(roles, Has.No.Member(ObjectIds.WellKnownRole_Operator),
|
|
"UserName matching must be ordinal (case-sensitive).");
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Gap 3: Application include / exclude semantics — all 4 quadrants
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_ApplicationFilterInclude_MatchingUri_GrantsRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator, AuthenticatedUser())), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddApplication(ObjectIds.WellKnownRole_Operator, "urn:listed:app")), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.SetApplicationsExclude(ObjectIds.WellKnownRole_Operator, false)), Is.True);
|
|
|
|
using Certificate cert = CertificateBuilder.Create("CN=Test")
|
|
.AddExtension(new X509SubjectAltNameExtension("urn:listed:app", ["localhost"]))
|
|
.SetRSAKeySize(CertificateFactory.DefaultKeySize).CreateForRSA();
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("op");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
var endpoint = new EndpointDescription { SecurityMode = MessageSecurityMode.Sign };
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, cert, endpoint);
|
|
Assert.That(roles, Has.Member(ObjectIds.WellKnownRole_Operator),
|
|
"Include mode with a matching ApplicationUri must grant the role.");
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_ApplicationFilterExclude_MatchingUri_DeniesRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator, AuthenticatedUser())), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddApplication(ObjectIds.WellKnownRole_Operator, "urn:blocked:app")), Is.True);
|
|
// Operator starts with ApplicationsExclude=true (default for newly
|
|
// created roles per Part 18 §4.2.2). Make this explicit.
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.SetApplicationsExclude(ObjectIds.WellKnownRole_Operator, true)), Is.True);
|
|
|
|
using Certificate cert = CertificateBuilder.Create("CN=Test")
|
|
.AddExtension(new X509SubjectAltNameExtension("urn:blocked:app", ["localhost"]))
|
|
.SetRSAKeySize(CertificateFactory.DefaultKeySize).CreateForRSA();
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("op");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
var endpoint = new EndpointDescription { SecurityMode = MessageSecurityMode.Sign };
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, cert, endpoint);
|
|
Assert.That(roles, Has.No.Member(ObjectIds.WellKnownRole_Operator),
|
|
"Exclude mode with a matching ApplicationUri must deny the role.");
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_ApplicationFilterExclude_NonMatchingUri_GrantsRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator, AuthenticatedUser())), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddApplication(ObjectIds.WellKnownRole_Operator, "urn:blocked:app")), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.SetApplicationsExclude(ObjectIds.WellKnownRole_Operator, true)), Is.True);
|
|
|
|
using Certificate cert = CertificateBuilder.Create("CN=Test")
|
|
.AddExtension(new X509SubjectAltNameExtension("urn:other:app", ["localhost"]))
|
|
.SetRSAKeySize(CertificateFactory.DefaultKeySize).CreateForRSA();
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("op");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
var endpoint = new EndpointDescription { SecurityMode = MessageSecurityMode.Sign };
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, cert, endpoint);
|
|
Assert.That(roles, Has.Member(ObjectIds.WellKnownRole_Operator),
|
|
"Exclude mode with a non-matching ApplicationUri must grant the role.");
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Gap 4: Endpoint include / exclude semantics — all 4 quadrants
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_EndpointFilterInclude_MatchingEndpoint_GrantsRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator, AuthenticatedUser())), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddEndpoint(ObjectIds.WellKnownRole_Operator,
|
|
new EndpointType { EndpointUrl = "opc.tcp://srv:4840" })), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.SetEndpointsExclude(ObjectIds.WellKnownRole_Operator, false)), Is.True);
|
|
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("op");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
var endpoint = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://srv:4840",
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt
|
|
};
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, null, endpoint);
|
|
Assert.That(roles, Has.Member(ObjectIds.WellKnownRole_Operator));
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_EndpointFilterInclude_NonMatchingEndpoint_DeniesRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator, AuthenticatedUser())), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddEndpoint(ObjectIds.WellKnownRole_Operator,
|
|
new EndpointType { EndpointUrl = "opc.tcp://allowed:4840" })), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.SetEndpointsExclude(ObjectIds.WellKnownRole_Operator, false)), Is.True);
|
|
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("op");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
var endpoint = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://other:4840",
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt
|
|
};
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, null, endpoint);
|
|
Assert.That(roles, Has.No.Member(ObjectIds.WellKnownRole_Operator));
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_EndpointFilterExclude_MatchingEndpoint_DeniesRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator, AuthenticatedUser())), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddEndpoint(ObjectIds.WellKnownRole_Operator,
|
|
new EndpointType { EndpointUrl = "opc.tcp://blocked:4840" })), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.SetEndpointsExclude(ObjectIds.WellKnownRole_Operator, true)), Is.True);
|
|
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("op");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
var endpoint = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://blocked:4840",
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt
|
|
};
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, null, endpoint);
|
|
Assert.That(roles, Has.No.Member(ObjectIds.WellKnownRole_Operator));
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_EndpointFilterExclude_NonMatchingEndpoint_GrantsRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator, AuthenticatedUser())), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddEndpoint(ObjectIds.WellKnownRole_Operator,
|
|
new EndpointType { EndpointUrl = "opc.tcp://blocked:4840" })), Is.True);
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.SetEndpointsExclude(ObjectIds.WellKnownRole_Operator, true)), Is.True);
|
|
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("op");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
var endpoint = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://allowed:4840",
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt
|
|
};
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, null, endpoint);
|
|
Assert.That(roles, Has.Member(ObjectIds.WellKnownRole_Operator));
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Identity claims: GroupId and Role rule paths
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_GroupIdRuleWithoutClaims_DoesNotGrantRole()
|
|
{
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator,
|
|
new IdentityMappingRuleType
|
|
{
|
|
CriteriaType = IdentityCriteriaType.GroupId,
|
|
Criteria = "admins"
|
|
})), Is.True);
|
|
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns("alice");
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity.Object, null, null);
|
|
Assert.That(roles, Has.No.Member(ObjectIds.WellKnownRole_Operator),
|
|
"GroupId rules require an IIdentityClaims.Groups value on the identity.");
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveGrantedRoles_RoleCriteriaMatchesAccessTokenRolesOnly()
|
|
{
|
|
string grantedRoleCriteria = ObjectIds.WellKnownRole_AuthenticatedUser.ToString();
|
|
var identity = new ClaimsTestIdentity(
|
|
tokenType: UserTokenType.UserName,
|
|
roles: ["Operator"]);
|
|
|
|
using var manager = new RoleManager();
|
|
Assert.That(ServiceResult.IsGood(
|
|
manager.AddIdentity(ObjectIds.WellKnownRole_Operator,
|
|
new IdentityMappingRuleType
|
|
{
|
|
CriteriaType = IdentityCriteriaType.Role,
|
|
Criteria = grantedRoleCriteria
|
|
})), Is.True);
|
|
IList<NodeId> roles = manager.ResolveGrantedRoles(identity, null, null);
|
|
Assert.That(roles, Has.No.Member(ObjectIds.WellKnownRole_Operator),
|
|
"Role criteria must read access-token role claims (Part 18 §4.4.4), not already-granted role NodeIds.");
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Gap 6: AuthenticatedUser / TrustedApplication non-empty criteria
|
|
// ----------------------------------------------------------------
|
|
|
|
[TestCase(IdentityCriteriaType.AuthenticatedUser)]
|
|
[TestCase(IdentityCriteriaType.TrustedApplication)]
|
|
public void AddIdentity_NonEmptyCriteriaForImpliedRule_ReturnsBadInvalidArgument(IdentityCriteriaType criteriaType)
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
new IdentityMappingRuleType
|
|
{
|
|
CriteriaType = criteriaType,
|
|
Criteria = "must-be-empty"
|
|
});
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Gap 7: UserName / Role / GroupId / Application empty criteria
|
|
// ----------------------------------------------------------------
|
|
|
|
[TestCase(IdentityCriteriaType.UserName)]
|
|
[TestCase(IdentityCriteriaType.Role)]
|
|
[TestCase(IdentityCriteriaType.GroupId)]
|
|
[TestCase(IdentityCriteriaType.Application)]
|
|
public void AddIdentity_EmptyCriteriaForExplicitRule_ReturnsBadInvalidArgument(IdentityCriteriaType criteriaType)
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
new IdentityMappingRuleType
|
|
{
|
|
CriteriaType = criteriaType,
|
|
Criteria = string.Empty
|
|
});
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Gap 8 + 9: Thumbprint boundary chars + odd-length / empty
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void AddIdentity_ThumbprintWithFullHexAlphabet_Succeeds()
|
|
{
|
|
// Exercise every hex character including the digit and letter
|
|
// boundaries '0', '9', 'A' and 'F' so boundary mutations on the
|
|
// hex-range checks (c <= '9', c <= 'F') are caught.
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
Thumbprint("0123456789ABCDEF"));
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_ThumbprintOddLength_ReturnsBadInvalidArgument()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
Thumbprint("ABC"));
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument),
|
|
"Odd-length thumbprints are not valid hex strings.");
|
|
}
|
|
|
|
[Test]
|
|
public void AddIdentity_ThumbprintEmpty_ReturnsBadInvalidArgument()
|
|
{
|
|
using var manager = new RoleManager();
|
|
ServiceResult result = manager.AddIdentity(ObjectIds.WellKnownRole_Observer,
|
|
Thumbprint(string.Empty));
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
}
|
|
}
|