Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayBrowseScopeProviderTests.cs
T
Joseph Doherty f40409a688
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m16s
ci / java (push) Successful in 2m5s
ci / portable (push) Failing after 5m9s
docs: strip task-tracking IDs from code comments and fill missing XML docs
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
2026-10-07 15:34:32 -04:00

117 lines
5.0 KiB
C#

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;
/// <summary>
/// Unit tests for <see cref="GatewayBrowseScopeProvider"/>, the host seam that feeds the
/// calling API key's <c>BrowseSubtrees</c> constraint into the shared library's Galaxy
/// browse RPCs via <c>IGalaxyBrowseScopeProvider</c>.
/// </summary>
public sealed class GatewayBrowseScopeProviderTests
{
/// <summary>
/// With an ambient identity whose effective constraints carry a browse subtree,
/// the provider returns exactly that subtree list to the library.
/// </summary>
[Fact]
public void ResolveBrowseSubtrees_WithCurrentIdentity_ReturnsConstraintBrowseSubtrees()
{
StubIdentityAccessor accessor = new(CreateIdentity(["AreaA"]));
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
Assert.Equal(["AreaA"], result);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void ResolveBrowseSubtrees_WithUnconstrainedIdentity_ReturnsEmptyList()
{
StubIdentityAccessor accessor = new(CreateIdentity([]));
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
Assert.NotNull(result);
Assert.Empty(result);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void ResolveBrowseSubtrees_WithNoCurrentIdentityAndApiKeyAuth_ThrowsPermissionDenied()
{
StubIdentityAccessor accessor = new(current: null);
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
RpcException exception = Assert.Throws<RpcException>(
() => provider.ResolveBrowseSubtrees(new TestServerCallContext()));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
}
/// <summary>
/// Under <c>Authentication:Mode=Disabled</c> 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.
/// </summary>
[Fact]
public void ResolveBrowseSubtrees_WithNoCurrentIdentityAndAuthDisabled_ReturnsEmptyList()
{
StubIdentityAccessor accessor = new(current: null);
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.Disabled);
IReadOnlyList<string>? 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<GatewayBrowseScopeProvider>.Instance);
private static ApiKeyIdentity CreateIdentity(IReadOnlyList<string> browseSubtrees) =>
new(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
DisplayName: "Operator",
Scopes: new HashSet<string>(StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = browseSubtrees });
private sealed class StubIdentityAccessor(ApiKeyIdentity? current) : IGatewayRequestIdentityAccessor
{
/// <inheritdoc />
public ApiKeyIdentity? Current { get; } = current;
/// <inheritdoc />
public IDisposable Push(ApiKeyIdentity identity) => throw new NotSupportedException();
}
}