using Grpc.Core; 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; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc; public sealed class GalaxyRepositoryGrpcServiceTests { /// Verifies that DiscoverHierarchy returns the requested page and totals. [Fact] public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals() { GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); DiscoverHierarchyReply reply = await service.DiscoverHierarchy( new DiscoverHierarchyRequest { PageSize = 2, }, new TestServerCallContext()); Assert.Equal(2, reply.Objects.Count); Assert.Equal("Object_001", reply.Objects[0].TagName); Assert.Equal("Object_002", reply.Objects[1].TagName); Assert.StartsWith("7:", reply.NextPageToken, StringComparison.Ordinal); Assert.EndsWith(":2", reply.NextPageToken, StringComparison.Ordinal); Assert.Equal(3, reply.TotalObjectCount); } /// Verifies that DiscoverHierarchy with a page token returns remaining objects. [Fact] public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects() { GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); DiscoverHierarchyReply firstPage = await service.DiscoverHierarchy( new DiscoverHierarchyRequest { PageSize = 2, }, new TestServerCallContext()); DiscoverHierarchyReply reply = await service.DiscoverHierarchy( new DiscoverHierarchyRequest { PageSize = 2, PageToken = firstPage.NextPageToken, }, new TestServerCallContext()); GalaxyObject item = Assert.Single(reply.Objects); Assert.Equal("Object_003", item.TagName); Assert.Equal("", reply.NextPageToken); Assert.Equal(3, reply.TotalObjectCount); } /// Verifies that DiscoverHierarchy with invalid paging arguments returns InvalidArgument. /// The page token to test. /// The page size to test. [Theory] [InlineData("-1", 1)] [InlineData("not-an-offset", 1)] [InlineData("7:4", 1)] [InlineData("6:2", 1)] [InlineData("", -1)] public async Task DiscoverHierarchy_WithInvalidPagingArguments_ReturnsInvalidArgument( string pageToken, int pageSize) { GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); RpcException exception = await Assert.ThrowsAsync( async () => await service.DiscoverHierarchy( new DiscoverHierarchyRequest { PageSize = pageSize, PageToken = pageToken, }, new TestServerCallContext())); Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); } /// Verifies that DiscoverHierarchy with subtree root and depth filters descendants. [Fact] public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants() { GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); DiscoverHierarchyReply reply = await service.DiscoverHierarchy( new DiscoverHierarchyRequest { RootContainedPath = "Area1/Line3", MaxDepth = 1, PageSize = 10, }, new TestServerCallContext()); Assert.Equal(["Line3", "Pump_001", "Valve_001"], reply.Objects.Select(obj => obj.TagName)); Assert.Equal(3, reply.TotalObjectCount); } /// Verifies that DiscoverHierarchy applies server-side filters and omits attributes. [Fact] public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes() { GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); DiscoverHierarchyReply reply = await service.DiscoverHierarchy( new DiscoverHierarchyRequest { RootTagName = "Area1", TagNameGlob = "Pump_*", AlarmBearingOnly = true, HistorizedOnly = true, IncludeAttributes = false, PageSize = 10, CategoryIds = { 10 }, TemplateChainContains = { "Pump" }, }, new TestServerCallContext()); GalaxyObject obj = Assert.Single(reply.Objects); Assert.Equal("Pump_001", obj.TagName); Assert.Empty(obj.Attributes); Assert.Equal(1, reply.TotalObjectCount); } /// Verifies that DiscoverHierarchy with filtered paging returns post-filter total. [Fact] public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal() { GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); DiscoverHierarchyReply first = await service.DiscoverHierarchy( new DiscoverHierarchyRequest { RootGobjectId = 1, PageSize = 1, CategoryIds = { 10 }, }, new TestServerCallContext()); DiscoverHierarchyReply second = await service.DiscoverHierarchy( new DiscoverHierarchyRequest { RootGobjectId = 1, PageSize = 1, PageToken = first.NextPageToken, CategoryIds = { 10 }, }, new TestServerCallContext()); GalaxyObject firstObject = Assert.Single(first.Objects); GalaxyObject secondObject = Assert.Single(second.Objects); Assert.Equal(2, first.TotalObjectCount); Assert.Equal(2, second.TotalObjectCount); Assert.NotEqual(firstObject.TagName, secondObject.TagName); } /// Verifies that DiscoverHierarchy with mismatched filter token returns InvalidArgument. [Fact] public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument() { GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); DiscoverHierarchyReply first = await service.DiscoverHierarchy( new DiscoverHierarchyRequest { PageSize = 1, CategoryIds = { 10 }, }, new TestServerCallContext()); RpcException exception = await Assert.ThrowsAsync( async () => await service.DiscoverHierarchy( new DiscoverHierarchyRequest { PageSize = 1, PageToken = first.NextPageToken, CategoryIds = { 11 }, }, new TestServerCallContext())); Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); } /// Verifies that DiscoverHierarchy with missing root returns NotFound. [Fact] public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound() { GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); RpcException exception = await Assert.ThrowsAsync( async () => await service.DiscoverHierarchy( new DiscoverHierarchyRequest { RootTagName = "Missing", }, new TestServerCallContext())); Assert.Equal(StatusCode.NotFound, exception.StatusCode); } private static GalaxyRepositoryGrpcService CreateService(GalaxyHierarchyCacheEntry entry) { GalaxyRepositoryOptions options = new() { ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", }; return new GalaxyRepositoryGrpcService( new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options), new StubGalaxyHierarchyCache(entry), new GalaxyDeployNotifier(), new GatewayRequestIdentityAccessor()); } private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList objects) { return GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, Sequence = 7, LastSuccessAt = DateTimeOffset.UtcNow, Objects = objects, Index = GalaxyHierarchyIndex.Build(objects), DashboardSummary = DashboardGalaxySummary.Unknown with { Status = DashboardGalaxyStatus.Healthy, ObjectCount = objects.Count, }, ObjectCount = objects.Count, }; } private static IReadOnlyList CreateObjects(int count) { return Enumerable.Range(1, count) .Select(index => new GalaxyObject { GobjectId = index, TagName = $"Object_{index:000}", BrowseName = $"Object_{index:000}", }) .ToArray(); } private static IReadOnlyList CreateFilterObjects() { return [ 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, TemplateChain = { "$Line", "$Base" }, }, new GalaxyObject { GobjectId = 3, TagName = "Pump_001", ContainedName = "Pump", BrowseName = "Pump_001", ParentGobjectId = 2, CategoryId = 10, TemplateChain = { "$Pump", "$Base" }, Attributes = { new GalaxyAttribute { AttributeName = "PV", FullTagReference = "Pump_001.PV", IsAlarm = true, IsHistorized = true, SecurityClassification = 2, }, }, }, new GalaxyObject { GobjectId = 4, TagName = "Valve_001", ContainedName = "Valve", BrowseName = "Valve_001", ParentGobjectId = 2, CategoryId = 11, TemplateChain = { "$Valve" }, Attributes = { new GalaxyAttribute { AttributeName = "PV", FullTagReference = "Valve_001.PV", }, }, }, new GalaxyObject { GobjectId = 5, TagName = "Other_001", ContainedName = "Other", BrowseName = "Other_001", CategoryId = 10, }, ]; } private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { /// public GalaxyHierarchyCacheEntry Current { get; } = current; /// public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// 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()); // 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); // 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); } }