Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/RoleManagerTests.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# 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>
2026-10-08 08:48:13 +02:00

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));
}
}
}