diff --git a/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs
index d9193f7..85a10fd 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs
@@ -26,6 +26,8 @@ public sealed class GalaxyRepositoryGrpcService(
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
private const int DefaultDiscoverPageSize = 1000;
private const int MaxDiscoverPageSize = 5000;
+ private const int DefaultBrowsePageSize = 500;
+ // MaxBrowsePageSize reuses MaxDiscoverPageSize (5000) — same cap.
///
public override async Task TestConnection(
@@ -107,6 +109,62 @@ public sealed class GalaxyRepositoryGrpcService(
return reply;
}
+ ///
+ public override async Task BrowseChildren(
+ BrowseChildrenRequest request,
+ ServerCallContext context)
+ {
+ await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
+ GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
+
+ if (!entry.HasData)
+ {
+ throw new RpcException(new Status(
+ StatusCode.Unavailable,
+ ResolveUnavailableMessage(entry)));
+ }
+
+ int pageSize = ResolveBrowsePageSize(request.PageSize);
+ IReadOnlyList browseSubtrees = ResolveBrowseSubtrees();
+
+ // Resolve the parent id once so the page-token signature can include it
+ // and the projector sees the same resolved id when memoizing.
+ int parentId = ResolveParentIdForToken(entry, request);
+ string filterSignature = GalaxyDb.GalaxyBrowseProjector.ComputeFilterSignature(
+ request, browseSubtrees, parentId);
+ PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
+
+ GalaxyDb.GalaxyBrowseChildrenResult result = GalaxyDb.GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ request,
+ browseSubtrees,
+ pageToken.Offset,
+ pageSize);
+
+ if (pageToken.Offset > result.TotalChildCount)
+ {
+ throw new RpcException(new Status(
+ StatusCode.InvalidArgument,
+ "BrowseChildren page_token is outside the current children set."));
+ }
+
+ BrowseChildrenReply reply = new()
+ {
+ TotalChildCount = result.TotalChildCount,
+ CacheSequence = (ulong)entry.Sequence,
+ };
+ reply.Children.Add(result.Children);
+ reply.ChildHasChildren.Add(result.ChildHasChildren);
+
+ int nextOffset = pageToken.Offset + result.Children.Count;
+ if (nextOffset < result.TotalChildCount)
+ {
+ reply.NextPageToken = FormatPageToken(entry.Sequence, result.FilterSignature, nextOffset);
+ }
+
+ return reply;
+ }
+
///
public override async Task WatchDeployEvents(
WatchDeployEventsRequest request,
@@ -213,6 +271,44 @@ public sealed class GalaxyRepositoryGrpcService(
return Math.Min(pageSize, MaxDiscoverPageSize);
}
+ private static int ResolveBrowsePageSize(int requested)
+ {
+ if (requested < 0)
+ {
+ throw new RpcException(new Status(
+ StatusCode.InvalidArgument,
+ "BrowseChildren page_size must be greater than zero when provided."));
+ }
+ int pageSize = requested == 0 ? DefaultBrowsePageSize : requested;
+ return Math.Min(pageSize, MaxDiscoverPageSize);
+ }
+
+ // Lightweight parent resolver used only for signature computation. Re-throws
+ // NotFound consistently with the projector so the error surface matches.
+ private static int ResolveParentIdForToken(
+ GalaxyDb.GalaxyHierarchyCacheEntry entry,
+ BrowseChildrenRequest request)
+ {
+ return request.ParentCase switch
+ {
+ BrowseChildrenRequest.ParentOneofCase.None => 0,
+ BrowseChildrenRequest.ParentOneofCase.ParentGobjectId =>
+ request.ParentGobjectId == 0 ? 0
+ : entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId)
+ ? request.ParentGobjectId
+ : throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
+ BrowseChildrenRequest.ParentOneofCase.ParentTagName =>
+ entry.Index.ObjectViews.FirstOrDefault(
+ v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
+ ?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
+ BrowseChildrenRequest.ParentOneofCase.ParentContainedPath =>
+ entry.Index.ObjectViews.FirstOrDefault(
+ v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
+ ?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
+ _ => 0,
+ };
+ }
+
private IReadOnlyList ResolveBrowseSubtrees()
{
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs
index 2ec1f2a..6773f86 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs
@@ -23,7 +23,8 @@ public sealed class GatewayGrpcScopeResolver
TestConnectionRequest or
GetLastDeployTimeRequest or
DiscoverHierarchyRequest or
- WatchDeployEventsRequest => GatewayScopes.MetadataRead,
+ WatchDeployEventsRequest or
+ BrowseChildrenRequest => GatewayScopes.MetadataRead,
_ => GatewayScopes.Admin
};
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs
index 0557f86..ee05cb9 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs
@@ -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;
}
+ /// Verifies that BrowseChildren returns root objects and the current cache sequence when called with no parent.
+ [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);
+ }
+
+ /// Verifies that BrowseChildren returns Unavailable when the cache's first load never completes.
+ [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.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(
+ async () => await service.BrowseChildren(
+ new BrowseChildrenRequest(),
+ new TestServerCallContext()));
+
+ Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
+ }
+
+ /// Verifies that a page token bound to a stale cache sequence is rejected with InvalidArgument.
+ [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(
+ 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);
+ }
+
+ /// Verifies that switching filters between paged BrowseChildren calls is rejected.
+ [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(
+ 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);
+ }
+
+ /// Verifies that an ApiKeyIdentity browse-subtrees constraint that matches nothing produces an empty child list.
+ [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.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(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
+ {
+ ///
+ public GalaxyHierarchyCacheEntry Current { get; } =
+ GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Unknown };
+
+ ///
+ public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) =>
+ Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ }
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs
index 7f55c77..0c81950 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs
@@ -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)