using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
///
/// Unit tests for , the host seam that feeds the
/// calling API key's BrowseSubtrees constraint into the shared library's Galaxy
/// browse RPCs via IGalaxyBrowseScopeProvider.
///
public sealed class GatewayBrowseScopeProviderTests
{
///
/// With an ambient identity whose effective constraints carry a browse subtree,
/// the provider returns exactly that subtree list to the library.
///
[Fact]
public void ResolveBrowseSubtrees_WithCurrentIdentity_ReturnsConstraintBrowseSubtrees()
{
StubIdentityAccessor accessor = new(CreateIdentity(["AreaA"]));
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
IReadOnlyList? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
Assert.Equal(["AreaA"], result);
}
///
/// An authenticated key with no BrowseSubtrees constraint is legitimately unscoped: the
/// empty list is what the library reads as "no scoping". This is the state that must
/// stay distinguishable from an absent identity, so it is asserted alongside its
/// denying twin below rather than left implicit.
///
[Fact]
public void ResolveBrowseSubtrees_WithUnconstrainedIdentity_ReturnsEmptyList()
{
StubIdentityAccessor accessor = new(CreateIdentity([]));
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
IReadOnlyList? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
Assert.NotNull(result);
Assert.Empty(result);
}
///
/// With API-key authentication enabled, an absent ambient identity means the
/// interceptor-ordering invariant is broken — the request's browse scope cannot be
/// established. The provider must DENY rather than resolve to the full hierarchy, because
/// a silently-unscoped browse is an invisible authorization bypass. The status code is
/// pinned: PermissionDenied is what the gRPC surface returns for an authorization failure
/// the caller cannot fix by re-authenticating.
///
[Fact]
public void ResolveBrowseSubtrees_WithNoCurrentIdentityAndApiKeyAuth_ThrowsPermissionDenied()
{
StubIdentityAccessor accessor = new(current: null);
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
RpcException exception = Assert.Throws(
() => provider.ResolveBrowseSubtrees(new TestServerCallContext()));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
}
///
/// Under Authentication:Mode=Disabled nothing authenticates, so an absent identity
/// is the expected steady state rather than a broken invariant: there is no key and no
/// constraint to scope by. The provider keeps returning unscoped, so disabling
/// authentication does not turn every Galaxy browse into a PermissionDenied.
///
[Fact]
public void ResolveBrowseSubtrees_WithNoCurrentIdentityAndAuthDisabled_ReturnsEmptyList()
{
StubIdentityAccessor accessor = new(current: null);
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.Disabled);
IReadOnlyList? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
Assert.NotNull(result);
Assert.Empty(result);
}
private static GatewayBrowseScopeProvider CreateProvider(
StubIdentityAccessor accessor,
AuthenticationMode mode) =>
new(
accessor,
Options.Create(new GatewayOptions
{
Authentication = new Server.Configuration.AuthenticationOptions { Mode = mode },
}),
NullLogger.Instance);
private static ApiKeyIdentity CreateIdentity(IReadOnlyList browseSubtrees) =>
new(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
DisplayName: "Operator",
Scopes: new HashSet(StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = browseSubtrees });
private sealed class StubIdentityAccessor(ApiKeyIdentity? current) : IGatewayRequestIdentityAccessor
{
///
public ApiKeyIdentity? Current { get; } = current;
///
public IDisposable Push(ApiKeyIdentity identity) => throw new NotSupportedException();
}
}