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)