test(gateway): restore end-to-end host browse-scope wiring coverage; strengthen snapshot summary assertion

Add GalaxyRepositoryHostWiringTests.BrowseChildren_BrowseSubtreesConstraintThroughHostWiring_FiltersChildren:
constructs the real GatewayRequestIdentityAccessor + GatewayBrowseScopeProvider, passes the
provider as IGalaxyBrowseScopeProvider to the lib GalaxyRepositoryGrpcService, and asserts
two children (unconstrained) then empty (BrowseSubtrees=["NonExistent"]) — proving the full
production authz-filtering chain is correctly wired.

Strengthen DashboardSnapshotServiceTests.GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache:
add Assert.Equal(2, TopTemplates.Count) and Assert.Contains($Area, InstanceCount==1) so the
test guards the complete summary output, not just the $Pump entry.
This commit is contained in:
Joseph Doherty
2026-06-25 12:32:49 -04:00
parent 719a57f444
commit 662dd1b958
2 changed files with 171 additions and 0 deletions
@@ -272,8 +272,10 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(3, snapshot.Galaxy.ObjectCount);
Assert.Equal(1, snapshot.Galaxy.AreaCount);
Assert.Equal(2, snapshot.Galaxy.AttributeCount);
Assert.Equal(2, snapshot.Galaxy.TopTemplates.Count);
Assert.Equal("$Pump", Assert.Single(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Pump").TemplateName);
Assert.Equal(2, snapshot.Galaxy.TopTemplates.First(t => t.TemplateName == "$Pump").InstanceCount);
Assert.Contains(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Area" && t.InstanceCount == 1);
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "UserDefined" && c.ObjectCount == 2);
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
}
@@ -0,0 +1,169 @@
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;
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: no identity pushed → <c>BrowseChildren</c> returns both children
/// (unconstrained). 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>
[Fact]
public async Task BrowseChildren_BrowseSubtreesConstraintThroughHostWiring_FiltersChildren()
{
// Arrange: construct the REAL host seam — not stubs.
GatewayRequestIdentityAccessor identityAccessor = new();
GatewayBrowseScopeProvider scopeProvider = new(identityAccessor);
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: no identity on the accessor → browse is unconstrained.
// Pump_001 and Valve_001 are both children of Line3 (GobjectId = 2).
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
{
/// <inheritdoc />
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
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
{
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) =>
throw new NotSupportedException("Not called during BrowseChildren.");
/// <inheritdoc />
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
throw new NotSupportedException("Not called during BrowseChildren.");
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
throw new NotSupportedException("Not called during BrowseChildren.");
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
throw new NotSupportedException("Not called during BrowseChildren.");
/// <inheritdoc />
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
throw new NotSupportedException("Not called during BrowseChildren.");
}
}