grpc: implement BrowseChildren handler + metadata:read scope

This commit is contained in:
Joseph Doherty
2026-05-28 13:08:45 -04:00
parent 87e22dd529
commit ba157b4b4f
4 changed files with 251 additions and 1 deletions
@@ -4,6 +4,7 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
@@ -335,4 +336,155 @@ public sealed class GalaxyRepositoryGrpcServiceTests
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>Verifies that BrowseChildren returns root objects and the current cache sequence when called with no parent.</summary>
[Fact]
public async Task BrowseChildren_RootCall_ReturnsRootsWithCacheSequence()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
BrowseChildrenReply reply = await service.BrowseChildren(
new BrowseChildrenRequest(),
new TestServerCallContext());
Assert.Equal(2, reply.Children.Count);
Assert.Equal("Area1", reply.Children[0].TagName);
Assert.Equal("Other_001", reply.Children[1].TagName);
Assert.Equal(7UL, reply.CacheSequence);
Assert.Equal(2, reply.TotalChildCount);
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
}
/// <summary>Verifies that BrowseChildren returns Unavailable when the cache's first load never completes.</summary>
[Fact]
public async Task BrowseChildren_FirstLoadNotComplete_ReturnsUnavailable()
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
};
GalaxyRepositoryGrpcService service = new(
new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options),
new NeverLoadsHierarchyCache(),
new GalaxyDeployNotifier(),
new GatewayRequestIdentityAccessor(),
NullLogger<GalaxyRepositoryGrpcService>.Instance);
// No caller-supplied CT so WaitForCacheBootstrap exits via its 5s internal budget
// (instead of re-throwing OperationCanceledException from the caller's CT). The
// handler then sees Status=Unknown and returns Unavailable.
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.BrowseChildren(
new BrowseChildrenRequest(),
new TestServerCallContext()));
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
}
/// <summary>Verifies that a page token bound to a stale cache sequence is rejected with InvalidArgument.</summary>
[Fact]
public async Task BrowseChildren_StaleToken_ReturnsInvalidArgument()
{
GalaxyRepositoryGrpcService firstService = CreateService(CreateEntry(CreateFilterObjects()));
BrowseChildrenReply firstReply = await firstService.BrowseChildren(
new BrowseChildrenRequest { PageSize = 1 },
new TestServerCallContext());
Assert.False(string.IsNullOrEmpty(firstReply.NextPageToken));
GalaxyHierarchyCacheEntry newerEntry = CreateEntry(CreateFilterObjects()) with { Sequence = 8 };
GalaxyRepositoryGrpcService secondService = CreateService(newerEntry);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await secondService.BrowseChildren(
new BrowseChildrenRequest
{
PageSize = 1,
PageToken = firstReply.NextPageToken,
},
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Contains("stale", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that switching filters between paged BrowseChildren calls is rejected.</summary>
[Fact]
public async Task BrowseChildren_FilterChangeBetweenPages_ReturnsInvalidArgument()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
BrowseChildrenReply firstReply = await service.BrowseChildren(
new BrowseChildrenRequest
{
ParentGobjectId = 2,
PageSize = 1,
},
new TestServerCallContext());
Assert.False(string.IsNullOrEmpty(firstReply.NextPageToken));
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.BrowseChildren(
new BrowseChildrenRequest
{
ParentGobjectId = 2,
PageSize = 1,
PageToken = firstReply.NextPageToken,
TagNameGlob = "Pump*",
},
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that an ApiKeyIdentity browse-subtrees constraint that matches nothing produces an empty child list.</summary>
[Fact]
public async Task BrowseChildren_BrowseSubtreesConstraint_FiltersChildren()
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
};
GatewayRequestIdentityAccessor identityAccessor = new();
GalaxyRepositoryGrpcService service = new(
new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options),
new StubGalaxyHierarchyCache(CreateEntry(CreateFilterObjects())),
new GalaxyDeployNotifier(),
identityAccessor,
NullLogger<GalaxyRepositoryGrpcService>.Instance);
// Sanity: with no identity pushed, both Pump and Valve come back under Line3 (id=2).
BrowseChildrenReply unconstrained = await service.BrowseChildren(
new BrowseChildrenRequest { ParentGobjectId = 2 },
new TestServerCallContext());
Assert.Equal(2, unconstrained.Children.Count);
ApiKeyIdentity identity = new(
KeyId: "test-key",
KeyPrefix: "mxgw_test",
DisplayName: "constraint-only",
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);
}
}
private sealed class NeverLoadsHierarchyCache : IGalaxyHierarchyCache
{
/// <inheritdoc />
public GalaxyHierarchyCacheEntry Current { get; } =
GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Unknown };
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) =>
Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
}
@@ -19,6 +19,7 @@ public sealed class GatewayGrpcScopeResolverTests
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(WatchDeployEventsRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(BrowseChildrenRequest), GatewayScopes.MetadataRead)]
public void ResolveRequiredScope_KnownRpcRequest_ReturnsExpectedScope(
Type requestType,
string expectedScope)