diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs index e20bca4..6c9e3e6 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs @@ -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); } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryHostWiringTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryHostWiringTests.cs new file mode 100644 index 0000000..4a7b429 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryHostWiringTests.cs @@ -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; + +/// +/// End-to-end wiring tests for the host-side browse-scope authorisation chain: +/// API-key constraints → → +/// → lib +/// → filtered BrowseChildren reply. +/// +/// These tests are NOT replaceable by the isolated +/// GatewayBrowseScopeProviderTests (which tests the provider in isolation) or +/// the lib's own test suite (which uses a fake IGalaxyBrowseScopeProvider). +/// They verify that the gateway's concrete is +/// correctly wired to the lib's and that the +/// filter actually propagates end-to-end into the returned child list. +/// +public sealed class GalaxyRepositoryHostWiringTests +{ + /// + /// Verifies the full production authz-filtering chain end-to-end. + /// Phase 1: no identity pushed → BrowseChildren returns both children + /// (unconstrained). Phase 2: identity with BrowseSubtrees = ["NonExistent"] + /// pushed via → the real + /// threads the constraint into the lib + /// service → BrowseChildren returns empty children. + /// + [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(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); + } + } + + /// + /// Returns a minimal hierarchy: Area1 at root, Line3 under Area1, + /// Pump_001 and Valve_001 under Line3. + /// + 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, + }, + ]; + + /// Immediately-ready stub: Current returns the seeded entry, loads are instant. + 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; + } + + /// + /// Stub repository that throws on every method: BrowseChildren uses only + /// the hierarchy cache, so the repository is never called during these tests. + /// + private sealed class StubGalaxyRepository : IGalaxyRepository + { + /// + public Task TestConnectionAsync(CancellationToken ct = default) => + throw new NotSupportedException("Not called during BrowseChildren."); + + /// + public Task GetLastDeployTimeAsync(CancellationToken ct = default) => + throw new NotSupportedException("Not called during BrowseChildren."); + + /// + public Task> GetHierarchyAsync(CancellationToken ct = default) => + throw new NotSupportedException("Not called during BrowseChildren."); + + /// + public Task> GetAttributesAsync(CancellationToken ct = default) => + throw new NotSupportedException("Not called during BrowseChildren."); + + /// + public Task> GetAlarmAttributesAsync(CancellationToken ct = default) => + throw new NotSupportedException("Not called during BrowseChildren."); + } +}