using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
///
/// Covers the #520 stage-2 identity cutover: the API-key authenticator and the interim role
/// mapping it feeds the two live authorization gates from.
///
public sealed class ApiKeyAuthenticationTests
{
private const string Pepper = "unit-test-pepper";
private const string Principal = "scadabridge-dcl";
// ---------- interim role mapping ----------
[Fact]
public void A_cluster_scoped_write_grant_confers_the_write_role()
{
var roles = ApiKeyInterimRoleMapper.Map(
[Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal);
roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
}
[Fact]
public void A_narrower_scoped_write_grant_confers_nothing_yet()
{
// THE load-bearing assertion of this stage. Nothing in the pipeline can honour a scope until
// stage 5, so honouring the FLAG alone would turn "WriteOperate on Equipment X" into
// server-wide write authority — the operator's narrow intent silently becoming broad. Under-
// granting is the only safe reading while scope is unenforceable.
foreach (var narrower in new[]
{
NodeAclScopeKind.Equipment, NodeAclScopeKind.UnsArea,
NodeAclScopeKind.UnsLine, NodeAclScopeKind.Namespace,
})
{
var roles = ApiKeyInterimRoleMapper.Map(
[Grant(narrower, NodePermissions.WriteOperate)], Principal);
roles.ShouldBeEmpty($"a {narrower}-scoped grant must not confer a server-wide role");
}
}
[Fact]
public void A_grant_for_a_different_principal_is_ignored()
{
var other = Grant(NodeAclScopeKind.Cluster, NodePermissions.Admin);
other.Principal = "someone-else";
ApiKeyInterimRoleMapper.Map([other], Principal).ShouldBeEmpty();
}
[Fact]
public void Alarm_acknowledge_maps_to_the_alarm_role_and_read_only_maps_to_nothing()
{
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge)], Principal)
.ShouldContain(OpcUaDataPlaneRoles.AlarmAck);
// ReadOnly carries no gated permission — reads are ungated today, so it must confer no role
// rather than quietly granting one.
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.ReadOnly)], Principal)
.ShouldBeEmpty();
}
[Fact]
public void Flags_union_across_several_cluster_grants()
{
var roles = ApiKeyInterimRoleMapper.Map(
[
Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge),
], Principal);
roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
roles.ShouldContain(OpcUaDataPlaneRoles.AlarmAck);
}
[Fact]
public void Principal_matching_is_case_insensitive_but_not_prefix_based()
{
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal.ToUpperInvariant())
.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
// A principal that merely starts with the granted name must not inherit it.
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal + "-readonly")
.ShouldBeEmpty();
}
// ---------- authenticator ----------
[Fact]
public async Task A_valid_key_authenticates_as_its_principal()
{
var (presented, store) = SetUp(NodePermissions.WriteOperate, NodeAclScopeKind.Cluster);
var result = await Auth(store).AuthenticateUserNameAsync("apikey", presented, default);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe(Principal);
result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
}
[Fact]
public async Task The_wrong_username_is_denied()
{
var (presented, store) = SetUp();
var result = await Auth(store).AuthenticateUserNameAsync("alice", presented, default);
result.Success.ShouldBeFalse();
}
[Fact]
public async Task A_wrong_secret_a_missing_key_and_a_malformed_credential_are_all_denied_identically()
{
var (presented, store) = SetUp();
var auth = Auth(store);
var wrongSecret = await auth.AuthenticateUserNameAsync("apikey", $"otua_{store.KeyId}_wrong", default);
var noSuchKey = await auth.AuthenticateUserNameAsync("apikey", "otua_ffffffff_whatever", default);
var malformed = await auth.AuthenticateUserNameAsync("apikey", "not-a-key", default);
var empty = await auth.AuthenticateUserNameAsync("apikey", "", default);
foreach (var r in new[] { wrongSecret, noSuchKey, malformed, empty })
{
r.Success.ShouldBeFalse();
// Identical message for every failure mode: the server must not be usable as an oracle for
// which half of the credential was wrong, or whether a key id exists at all.
r.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
}
// ...and the valid one still works, so the above is not passing because everything is denied.
(await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeTrue();
}
[Fact]
public async Task A_store_that_throws_denies_rather_than_propagating()
{
// The interface contract forbids throwing — an exception escapes into the SDK session-activation
// path and surfaces as BadInternalError instead of a clean rejection.
var store = new ThrowingApiKeyStore();
var result = await Auth(store).AuthenticateUserNameAsync("apikey", "otua_aabbccdd_secret", default);
result.Success.ShouldBeFalse();
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
}
[Fact]
public async Task An_unset_pepper_denies_rather_than_authenticating_everything()
{
var (presented, store) = SetUp();
var auth = new ApiKeyOpcUaUserAuthenticator(
store,
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "" }),
NullLogger.Instance);
(await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeFalse();
}
// ---------- helpers ----------
private static NodeAcl Grant(NodeAclScopeKind scope, NodePermissions flags) => new()
{
NodeAclId = $"ACL-{scope}-{flags}",
ClusterId = "MAIN",
Principal = Principal,
ScopeKind = scope,
ScopeId = scope == NodeAclScopeKind.Cluster ? null : "SOME-SCOPE",
PermissionFlags = flags,
};
private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
store,
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }),
NullLogger.Instance);
private static (string Presented, FakeApiKeyStore Store) SetUp(
NodePermissions flags = NodePermissions.None,
NodeAclScopeKind scope = NodeAclScopeKind.Cluster)
{
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
var key = new ApiKey
{
ApiKeyId = apiKeyId,
Name = "test key",
SecretHash = hash,
Principal = Principal,
Enabled = true,
CreatedUtc = DateTime.UtcNow,
};
var grants = flags == NodePermissions.None ? [] : new List { Grant(scope, flags) };
return (presented, new FakeApiKeyStore(key, grants));
}
private sealed class FakeApiKeyStore(ApiKey key, IReadOnlyList grants) : IApiKeyStore
{
public string KeyId => key.ApiKeyId;
public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
Task.FromResult(string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal)
? new ApiKeyLookup(key, grants)
: null);
}
private sealed class ThrowingApiKeyStore : IApiKeyStore
{
public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
throw new InvalidOperationException("store is down");
}
}