Remove static Users auth, use shared QualityMapper for historian, simplify LDAP permission checks

- Remove ConfigUserAuthenticationProvider and Users property — LDAP is the only auth mechanism
- Fix historian quality mapping to use existing QualityMapper (OPC DA quality bytes, not custom mapping)
- Add AppRoles constants, unify HasWritePermission/HasAlarmAckPermission into shared HasRole helper
- Hoist write permission check out of per-item loop, eliminate redundant _ldapRolesEnabled field
- Update docs (Configuration.md, Security.md, OpcUaServer.md, HistoricalDataAccess.md)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-28 19:23:20 -04:00
parent 74107ea95e
commit d9463d6998
19 changed files with 93 additions and 273 deletions

View File

@@ -87,7 +87,7 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
## LDAP Authentication
The server supports LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `ReadWrite` (read/write tags), `AlarmAck` (alarm acknowledgment). The `IUserAuthenticationProvider` interface is pluggable — `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `ReadWrite` (read/write tags), `AlarmAck` (alarm acknowledgment). `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
## Library Preferences

View File

@@ -114,13 +114,10 @@ Controls user authentication and write authorization for the OPC UA server. Defi
|----------|------|---------|-------------|
| `AllowAnonymous` | `bool` | `true` | Accepts anonymous client connections when `true` |
| `AnonymousCanWrite` | `bool` | `true` | Permits anonymous users to write when `true` |
| `Users` | `List<UserCredential>` | `[]` | List of username/password credentials for `UserName` token authentication |
Each entry in the `Users` list has two properties: `Username` (string) and `Password` (string). The `Users` list is ignored when `Ldap.Enabled` is `true`.
#### LDAP Authentication
When `Ldap.Enabled` is `true`, credentials are validated against the configured LDAP server and group membership determines OPC UA permissions. The `Users` list is ignored.
When `Ldap.Enabled` is `true`, credentials are validated against the configured LDAP server and group membership determines OPC UA permissions.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
@@ -148,13 +145,12 @@ When LDAP is enabled, authenticated users receive permissions based on their LDA
Users can belong to multiple groups. The `admin` user in the default GLAuth configuration belongs to all three groups.
Example with LDAP authentication:
Example configuration:
```json
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": false,
"Users": [],
"Ldap": {
"Enabled": true,
"Host": "localhost",
@@ -171,19 +167,6 @@ Example with LDAP authentication:
}
```
Example with static user list (no LDAP):
```json
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": false,
"Users": [
{ "Username": "operator", "Password": "op123" },
{ "Username": "engineer", "Password": "eng456" }
]
}
```
### Security
Controls OPC UA transport security profiles and certificate handling. Defined in `SecurityProfileConfiguration`. See [Security Guide](security.md) for detailed usage.
@@ -321,7 +304,9 @@ Integration tests use this constructor to inject substitute implementations of `
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": true,
"Users": []
"Ldap": {
"Enabled": false
}
},
"Security": {
"Profiles": ["None"],

View File

@@ -44,7 +44,7 @@ The `TOP` clause is included only when `maxValues > 0` (the OPC UA client specif
- `Value` column (double) takes priority over `vValue` (string). If both are null, the value is null.
- `SourceTimestamp` and `ServerTimestamp` are both set to the `DateTime` column.
- `StatusCode` is mapped from the Historian `Quality` byte via `MapQuality`.
- `StatusCode` is mapped from the Historian `Quality` byte via `QualityMapper` (the same OPC DA quality byte mapping used for live MXAccess data).
## Aggregate Reads
@@ -65,16 +65,15 @@ Null aggregate values return `BadNoData` status rather than `Good` with a null v
## Quality Mapping
`MapQuality` converts Wonderware Historian quality bytes to OPC UA status codes:
The Historian stores standard OPC DA quality bytes, the same format used by MXAccess at runtime. The quality byte is passed through the shared `QualityMapper` pipeline (`MapFromMxAccessQuality``MapToOpcUaStatusCode`), which maps the OPC DA quality families to OPC UA status codes:
| Historian Quality | OPC UA StatusCode |
|---|---|
| 0 | `Good` |
| 1 | `Bad` |
| 2-127 | `Bad` |
| 128+ | `Uncertain` |
| Historian Quality Byte | OPC DA Family | OPC UA StatusCode |
|---|---|---|
| 0-63 | Bad | `Bad` (with sub-code when an exact enum match exists) |
| 64-191 | Uncertain | `Uncertain` (with sub-code when an exact enum match exists) |
| 192+ | Good | `Good` (with sub-code when an exact enum match exists) |
This follows the Wonderware convention where quality 0 indicates a good sample, 1 indicates explicitly bad data, and values at or above 128 represent uncertain quality (e.g., interpolated or suspect values).
See `Domain/QualityMapper.cs` and `Domain/Quality.cs` for the full mapping table and sub-code definitions.
## Aggregate Function Mapping

View File

@@ -63,15 +63,15 @@ The `ServiceLevel` is updated whenever MXAccess connection state changes or Gala
`UserTokenPolicies` are dynamically configured based on the `Authentication` settings in `appsettings.json`:
- An `Anonymous` user token policy is added when `AllowAnonymous` is `true` (the default).
- A `UserName` user token policy is added when the `Users` list contains at least one entry.
- A `UserName` user token policy is added when `Ldap.Enabled` is `true`.
Both policies can be active simultaneously, allowing clients to connect with or without credentials.
### Session impersonation
When a client presents `UserName` credentials, the server validates them through `IUserAuthenticationProvider`. If the credentials do not match any entry in the configured `Users` list, the session is rejected.
When a client presents `UserName` credentials, the server validates them through `IUserAuthenticationProvider`. If LDAP authentication is enabled, credentials are validated via LDAP bind and group membership determines the user's application-level roles (`ReadOnly`, `ReadWrite`, `AlarmAck`). If validation fails, the session is rejected.
On successful validation, the session identity is set to a `RoleBasedIdentity` that carries the user's granted role IDs. Authenticated users receive the `WellKnownRole_AuthenticatedUser` role. Anonymous connections receive the `WellKnownRole_Anonymous` role. These roles are used downstream by the write override to enforce `AnonymousCanWrite` restrictions.
On successful validation, the session identity is set to a `RoleBasedIdentity` that carries the user's granted role IDs. Authenticated users receive the `WellKnownRole_AuthenticatedUser` role. Anonymous connections receive the `WellKnownRole_Anonymous` role. When LDAP is enabled, application-level roles from group membership control write and alarm-ack permissions. Without LDAP, `AnonymousCanWrite` controls whether anonymous users can write.
## Certificate handling

View File

@@ -147,19 +147,23 @@ Remove `None` from the `Profiles` list to prevent unencrypted connections:
}
```
### 3. Configure named users
### 3. Configure LDAP authentication
Disable anonymous access and define named users in the `Authentication` section. Use `AnonymousCanWrite` to control whether anonymous clients (if still allowed) can write:
Enable LDAP authentication to validate credentials against the GLAuth server. LDAP group membership controls what each user can do (read, write, alarm acknowledgment). See [Configuration Guide](Configuration.md) for the full LDAP property reference.
```json
{
"Authentication": {
"AllowAnonymous": false,
"AnonymousCanWrite": false,
"Users": [
{ "Username": "operator", "Password": "secure-password" },
{ "Username": "viewer", "Password": "read-only-password" }
]
"Ldap": {
"Enabled": true,
"Host": "localhost",
"Port": 3893,
"BaseDN": "dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123"
}
}
}
```

View File

@@ -1,5 +1,3 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
@@ -18,32 +16,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public bool AnonymousCanWrite { get; set; } = true;
/// <summary>
/// Gets or sets the list of username/password pairs accepted for authenticated access.
/// Ignored when Ldap.Enabled is true.
/// </summary>
public List<UserCredential> Users { get; set; } = new List<UserCredential>();
/// <summary>
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true,
/// credentials are validated against the LDAP server and the Users list is ignored.
/// credentials are validated against the LDAP server and group membership determines permissions.
/// </summary>
public LdapConfiguration Ldap { get; set; } = new LdapConfiguration();
}
/// <summary>
/// A username/password pair for OPC UA user authentication.
/// </summary>
public class UserCredential
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public string Username { get; set; } = "";
/// <summary>
/// Gets or sets the password.
/// </summary>
public string Password { get; set; } = "";
}
}

View File

@@ -119,15 +119,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
}
if (config.Authentication.Users.Count > 0)
{
Log.Warning("Authentication.Users list is ignored when Ldap is enabled");
}
}
else if (config.Authentication.Users.Count > 0)
{
Log.Information("Authentication.Users configured: {Count} user(s)", config.Authentication.Users.Count);
}
// Redundancy

View File

@@ -1,25 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Validates credentials against a static list from appsettings.json configuration.
/// </summary>
public class ConfigUserAuthenticationProvider : IUserAuthenticationProvider
{
private readonly Dictionary<string, string> _users;
public ConfigUserAuthenticationProvider(List<UserCredential> users)
{
_users = users.ToDictionary(u => u.Username, u => u.Password, StringComparer.OrdinalIgnoreCase);
}
public bool ValidateCredentials(string username, string password)
{
return _users.TryGetValue(username, out var expected) && expected == password;
}
}
}

View File

@@ -22,8 +22,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Returns the set of application-level roles granted to the user.
/// Known roles: "ReadOnly", "ReadWrite", "AlarmAck".
/// </summary>
IReadOnlyList<string> GetUserRoles(string username);
}
/// <summary>
/// Well-known application-level role names used for permission enforcement.
/// </summary>
public static class AppRoles
{
public const string ReadOnly = "ReadOnly";
public const string ReadWrite = "ReadWrite";
public const string AlarmAck = "AlarmAck";
}
}

View File

@@ -23,9 +23,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
_config = config;
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ config.ReadOnlyGroup, "ReadOnly" },
{ config.ReadWriteGroup, "ReadWrite" },
{ config.AlarmAckGroup, "AlarmAck" }
{ config.ReadOnlyGroup, AppRoles.ReadOnly },
{ config.ReadWriteGroup, AppRoles.ReadWrite },
{ config.AlarmAckGroup, AppRoles.AlarmAck }
};
}
@@ -73,7 +73,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
if (response.Entries.Count == 0)
{
Log.Warning("LDAP search returned no entries for {Username}", username);
return new[] { "ReadOnly" }; // safe fallback
return new[] { AppRoles.ReadOnly }; // safe fallback
}
var entry = response.Entries[0];
@@ -81,7 +81,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
if (memberOf == null || memberOf.Count == 0)
{
Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username);
return new[] { "ReadOnly" };
return new[] { AppRoles.ReadOnly };
}
var roles = new List<string>();
@@ -99,7 +99,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
if (roles.Count == 0)
{
Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username);
roles.Add("ReadOnly");
roles.Add(AppRoles.ReadOnly);
}
Log.Debug("LDAP roles for {Username}: [{Roles}]", username, string.Join(", ", roles));
@@ -109,7 +109,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
catch (Exception ex)
{
Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username);
return new[] { "ReadOnly" };
return new[] { AppRoles.ReadOnly };
}
}

View File

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Opc.Ua;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
{
@@ -72,7 +73,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
Value = new Variant(value),
SourceTimestamp = timestamp,
ServerTimestamp = timestamp,
StatusCode = MapQuality(quality)
StatusCode = QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality))
});
}
@@ -131,20 +132,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
return results;
}
/// <summary>
/// Maps Wonderware Historian quality codes to OPC UA StatusCodes.
/// </summary>
/// <param name="quality">The raw Wonderware Historian quality byte stored with a historical sample.</param>
public static StatusCode MapQuality(byte quality)
{
if (quality == 0)
return StatusCodes.Good;
if (quality == 1)
return StatusCodes.Bad;
if (quality >= 128)
return StatusCodes.Uncertain;
return StatusCodes.Bad;
}
/// <summary>
/// Maps an OPC UA aggregate NodeId to the corresponding Historian column name.

View File

@@ -26,7 +26,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly bool _alarmTrackingEnabled;
private readonly bool _anonymousCanWrite;
private readonly Func<string?, IReadOnlyList<string>?>? _appRoleLookup;
private readonly bool _ldapRolesEnabled;
private readonly string _namespaceUri;
// NodeId → full_tag_reference for read/write resolution
@@ -195,8 +194,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
HistorianDataSource? historianDataSource = null,
bool alarmTrackingEnabled = false,
bool anonymousCanWrite = true,
Func<string?, IReadOnlyList<string>?>? appRoleLookup = null,
bool ldapRolesEnabled = false)
Func<string?, IReadOnlyList<string>?>? appRoleLookup = null)
: base(server, configuration, namespaceUri)
{
_namespaceUri = namespaceUri;
@@ -206,7 +204,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_alarmTrackingEnabled = alarmTrackingEnabled;
_anonymousCanWrite = anonymousCanWrite;
_appRoleLookup = appRoleLookup;
_ldapRolesEnabled = ldapRolesEnabled;
// Wire up data change delivery
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
@@ -1103,6 +1100,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
base.Write(context, nodesToWrite, errors);
var canWrite = HasWritePermission(context);
for (int i = 0; i < nodesToWrite.Count; i++)
{
if (nodesToWrite[i].AttributeId != Attributes.Value)
@@ -1112,8 +1111,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable)
continue;
// Enforce role-based write access
if (!HasWritePermission(context))
if (!canWrite)
{
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue;
@@ -1165,14 +1163,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private bool HasWritePermission(OperationContext context)
{
// When LDAP roles are active, check for ReadWrite role
if (_ldapRolesEnabled && _appRoleLookup != null)
{
var username = context.UserIdentity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null;
var roles = _appRoleLookup(username);
if (roles == null) return false; // unknown user
return roles.Contains("ReadWrite");
}
if (_appRoleLookup != null)
return HasRole(context.UserIdentity, Domain.AppRoles.ReadWrite);
// Legacy behavior: reject anonymous writes when AnonymousCanWrite is false
if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null &&
@@ -1186,14 +1178,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private bool HasAlarmAckPermission(ISystemContext context)
{
if (!_ldapRolesEnabled || _appRoleLookup == null)
return true; // no LDAP restrictions without LDAP roles
if (_appRoleLookup == null)
return true;
var opContext = context as SystemContext;
var username = opContext?.UserIdentity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null;
var roles = _appRoleLookup(username);
if (roles == null) return false;
return roles.Contains("AlarmAck");
var identity = (context as SystemContext)?.UserIdentity;
return HasRole(identity, Domain.AppRoles.AlarmAck);
}
private bool HasRole(IUserIdentity? identity, string requiredRole)
{
var username = identity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null;
var roles = _appRoleLookup!(username);
return roles != null && roles.Contains(requiredRole);
}
private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, out object updatedArray)

View File

@@ -88,8 +88,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
LdapRolesEnabled ? (Func<string?, IReadOnlyList<string>?>)GetUserAppRoles : null,
LdapRolesEnabled);
LdapRolesEnabled ? (Func<string?, IReadOnlyList<string>?>)GetUserAppRoles : null);
var nodeManagers = new List<INodeManager> { _nodeManager };
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());

View File

@@ -231,7 +231,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var policies = new UserTokenPolicyCollection();
if (_authConfig.AllowAnonymous)
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
if (_authConfig.Users.Count > 0 || _authConfig.Ldap.Enabled)
if (_authConfig.Ldap.Enabled)
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
if (policies.Count == 0)

View File

@@ -161,21 +161,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
var historianDataSource = _config.Historian.Enabled
? new Historian.HistorianDataSource(_config.Historian)
: null;
Domain.IUserAuthenticationProvider? authProvider;
Domain.IUserAuthenticationProvider? authProvider = null;
if (_config.Authentication.Ldap.Enabled)
{
authProvider = new Domain.LdapAuthenticationProvider(_config.Authentication.Ldap);
Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})",
_config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port, _config.Authentication.Ldap.BaseDN);
}
else if (_config.Authentication.Users.Count > 0)
{
authProvider = new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users);
}
else
{
authProvider = null;
}
_serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource,
_config.Authentication, authProvider, _config.Security, _config.Redundancy);

View File

@@ -36,7 +36,6 @@
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": false,
"Users": [],
"Ldap": {
"Enabled": false,
"Host": "localhost",

View File

@@ -1,20 +0,0 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
{
/// <summary>
/// Placeholder integration test that keeps the integration test project wired into the solution.
/// </summary>
public class SampleIntegrationTest
{
/// <summary>
/// Confirms that the integration test assembly is executing.
/// </summary>
[Fact]
public void Placeholder_ShouldPass()
{
true.ShouldBeTrue();
}
}
}

View File

@@ -1,4 +1,3 @@
using System.Collections.Generic;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
@@ -8,59 +7,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
{
public class UserAuthenticationTests
{
[Fact]
public void ValidCredentials_ReturnsTrue()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
new UserCredential { Username = "operator", Password = "op123" }
});
provider.ValidateCredentials("operator", "op123").ShouldBeTrue();
}
[Fact]
public void WrongPassword_ReturnsFalse()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
new UserCredential { Username = "operator", Password = "op123" }
});
provider.ValidateCredentials("operator", "wrong").ShouldBeFalse();
}
[Fact]
public void UnknownUsername_ReturnsFalse()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
new UserCredential { Username = "operator", Password = "op123" }
});
provider.ValidateCredentials("unknown", "op123").ShouldBeFalse();
}
[Fact]
public void Username_IsCaseInsensitive()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
new UserCredential { Username = "Operator", Password = "op123" }
});
provider.ValidateCredentials("operator", "op123").ShouldBeTrue();
provider.ValidateCredentials("OPERATOR", "op123").ShouldBeTrue();
}
[Fact]
public void EmptyUserList_RejectsAll()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>());
provider.ValidateCredentials("anyone", "anything").ShouldBeFalse();
}
[Fact]
public void AuthenticationConfiguration_Defaults()
{
@@ -68,7 +14,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
config.AllowAnonymous.ShouldBeTrue();
config.AnonymousCanWrite.ShouldBeTrue();
config.Users.ShouldBeEmpty();
}
[Fact]
@@ -232,13 +177,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
(provider is IRoleProvider).ShouldBeTrue();
}
[Fact]
public void ConfigUserAuthenticationProvider_DoesNotImplementIRoleProvider()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>());
(provider is IRoleProvider).ShouldBeFalse();
}
[Fact]
public void LdapAuthenticationProvider_ConnectionFailure_ReturnsFalse()
{

View File

@@ -1,54 +1,44 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Historian;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
{
public class HistorianQualityMappingTests
{
/// <summary>
/// Verifies that the Historian good-quality sentinel is surfaced to OPC UA clients as a good status code.
/// </summary>
[Fact]
public void Quality0_MapsToGood()
{
HistorianDataSource.MapQuality(0).ShouldBe(StatusCodes.Good);
}
private static StatusCode MapHistorianQuality(byte quality)
=> QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality));
/// <summary>
/// Verifies that the Historian bad-quality sentinel is surfaced to OPC UA clients as a bad status code.
/// </summary>
[Fact]
public void Quality1_MapsToBad()
{
HistorianDataSource.MapQuality(1).ShouldBe(StatusCodes.Bad);
}
/// <summary>
/// Verifies that Historian uncertainty quality bands are translated into OPC UA uncertain results.
/// </summary>
/// <param name="quality">A Wonderware Historian quality byte in the uncertain range.</param>
[Theory]
[InlineData(128)]
[InlineData(133)]
[InlineData(192)]
public void QualityAbove128_MapsToUncertain(byte quality)
[InlineData(192)] // Quality.Good
[InlineData(216)] // Quality.GoodLocalOverride
public void GoodQualityRange_MapsToGood(byte quality)
{
HistorianDataSource.MapQuality(quality).ShouldBe(StatusCodes.Uncertain);
StatusCode.IsGood(MapHistorianQuality(quality)).ShouldBeTrue();
}
/// <summary>
/// Verifies that nonzero non-uncertain Historian quality values are treated as bad historical samples.
/// </summary>
/// <param name="quality">A Wonderware Historian quality byte that should map to an OPC UA bad status.</param>
[Theory]
[InlineData(2)]
[InlineData(50)]
[InlineData(127)]
public void OtherBadQualities_MapToBad(byte quality)
[InlineData(64)] // Quality.Uncertain
[InlineData(68)] // Quality.UncertainLastUsable
[InlineData(80)] // Quality.UncertainSensorNotAccurate
[InlineData(88)] // Quality.UncertainSubNormal
[InlineData(128)] // Uncertain range (no exact enum match)
public void UncertainQualityRange_MapsToUncertain(byte quality)
{
HistorianDataSource.MapQuality(quality).ShouldBe(StatusCodes.Bad);
StatusCode.IsUncertain(MapHistorianQuality(quality)).ShouldBeTrue();
}
[Theory]
[InlineData(0)] // Quality.Bad
[InlineData(1)] // Bad range
[InlineData(4)] // Quality.BadConfigError
[InlineData(8)] // Quality.BadNotConnected
[InlineData(20)] // Quality.BadCommFailure
[InlineData(50)] // Bad range (no exact enum match)
public void BadQualityRange_MapsToBad(byte quality)
{
StatusCode.IsBad(MapHistorianQuality(quality)).ShouldBeTrue();
}
}
}