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