Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryHostWiringTests.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

210 lines
9.7 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;
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.Gateway.Grpc;
/// <summary>
/// End-to-end wiring tests for the host-side browse-scope authorisation chain:
/// API-key constraints → <see cref="GatewayRequestIdentityAccessor.Push"/> →
/// <see cref="GatewayBrowseScopeProvider.ResolveBrowseSubtrees"/> → lib
/// <see cref="GalaxyRepositoryGrpcService"/> → filtered <c>BrowseChildren</c> reply.
///
/// These tests are NOT replaceable by the isolated
/// <c>GatewayBrowseScopeProviderTests</c> (which tests the provider in isolation) or
/// the lib's own test suite (which uses a fake <c>IGalaxyBrowseScopeProvider</c>).
/// They verify that the gateway's concrete <see cref="GatewayBrowseScopeProvider"/> is
/// correctly wired to the lib's <see cref="GalaxyRepositoryGrpcService"/> and that the
/// filter actually propagates end-to-end into the returned child list.
/// </summary>
public sealed class GalaxyRepositoryHostWiringTests
{
/// <summary>
/// Verifies the full production authz-filtering chain end-to-end.
/// Phase 1: an identity carrying no <c>BrowseSubtrees</c> pushed → <c>BrowseChildren</c>
/// returns both children (unconstrained). That, not an absent identity, is production's
/// unscoped case — with <c>Authentication:Mode=ApiKey</c> an absent identity
/// now denies, which the isolated provider test pins.
/// Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
/// pushed via <see cref="GatewayRequestIdentityAccessor"/> → the real
/// <see cref="GatewayBrowseScopeProvider"/> threads the constraint into the lib
/// service → <c>BrowseChildren</c> returns empty children.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BrowseChildren_BrowseSubtreesConstraintThroughHostWiring_FiltersChildren()
{
// Arrange: construct the REAL host seam — not stubs.
GatewayRequestIdentityAccessor identityAccessor = new();
GatewayBrowseScopeProvider scopeProvider = new(
identityAccessor,
Options.Create(new GatewayOptions
{
Authentication = new Server.Configuration.AuthenticationOptions
{
Mode = AuthenticationMode.ApiKey,
},
}),
NullLogger<GatewayBrowseScopeProvider>.Instance);
GalaxyObject[] objects = CreateFilterObjects();
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 7,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
ObjectCount = objects.Length,
};
// Inject the REAL GatewayBrowseScopeProvider as the IGalaxyBrowseScopeProvider.
GalaxyRepositoryGrpcService service = new(
new StubGalaxyRepository(),
new StubGalaxyHierarchyCache(entry),
new GalaxyDeployNotifier(),
scopeProvider);
// Phase 1: an authenticated identity with no BrowseSubtrees constraint → browse is
// unconstrained. Pump_001 and Valve_001 are both children of Line3 (GobjectId = 2).
ApiKeyIdentity unscopedIdentity = new(
KeyId: "test-key",
KeyPrefix: "mxgw_test",
DisplayName: "unscoped",
Scopes: new HashSet<string>(StringComparer.Ordinal) { GatewayScopes.MetadataRead },
Constraints: ApiKeyConstraints.Empty);
using (identityAccessor.Push(unscopedIdentity))
{
BrowseChildrenReply unconstrained = await service.BrowseChildren(
new BrowseChildrenRequest { ParentGobjectId = 2 },
new TestServerCallContext());
Assert.Equal(2, unconstrained.Children.Count);
}
// Phase 2: push a constraint whose BrowseSubtrees do not match any seeded object.
// GatewayBrowseScopeProvider.ResolveBrowseSubtrees must return ["NonExistent"],
// the lib service must honour it, and the child list must be empty.
ApiKeyIdentity identity = new(
KeyId: "test-key",
KeyPrefix: "mxgw_test",
DisplayName: "constraint-test",
Scopes: new HashSet<string>(StringComparer.Ordinal) { GatewayScopes.MetadataRead },
Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = new[] { "NonExistent" } });
using (identityAccessor.Push(identity))
{
BrowseChildrenReply constrained = await service.BrowseChildren(
new BrowseChildrenRequest { ParentGobjectId = 2 },
new TestServerCallContext());
Assert.Empty(constrained.Children);
Assert.Equal(0, constrained.TotalChildCount);
}
}
/// <summary>
/// Returns a minimal hierarchy: Area1 at root, Line3 under Area1,
/// Pump_001 and Valve_001 under Line3.
/// </summary>
private static GalaxyObject[] CreateFilterObjects() =>
[
new GalaxyObject
{
GobjectId = 1,
TagName = "Area1",
ContainedName = "Area1",
BrowseName = "Area1",
IsArea = true,
CategoryId = 13,
},
new GalaxyObject
{
GobjectId = 2,
TagName = "Line3",
ContainedName = "Line3",
BrowseName = "Line3",
ParentGobjectId = 1,
CategoryId = 10,
},
new GalaxyObject
{
GobjectId = 3,
TagName = "Pump_001",
ContainedName = "Pump",
BrowseName = "Pump_001",
ParentGobjectId = 2,
CategoryId = 10,
},
new GalaxyObject
{
GobjectId = 4,
TagName = "Valve_001",
ContainedName = "Valve",
BrowseName = "Valve_001",
ParentGobjectId = 2,
CategoryId = 11,
},
];
/// <summary>Immediately-ready stub: Current returns the seeded entry, loads are instant.</summary>
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <summary>Gets the seeded cache entry passed to the stub's constructor.</summary>
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <summary>No-op: the stub's entry is always considered already loaded.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>No-op: the stub's entry is always considered already loaded.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>
/// Stub repository that throws on every method: <c>BrowseChildren</c> uses only
/// the hierarchy cache, so the repository is never called during these tests.
/// </summary>
private sealed class StubGalaxyRepository : IGalaxyRepository
{
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task<bool> TestConnectionAsync(CancellationToken ct = default) =>
throw new NotSupportedException("Not called during BrowseChildren.");
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
throw new NotSupportedException("Not called during BrowseChildren.");
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
throw new NotSupportedException("Not called during BrowseChildren.");
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
throw new NotSupportedException("Not called during BrowseChildren.");
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
throw new NotSupportedException("Not called during BrowseChildren.");
}
}