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.");
+ }
+}