using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.GalaxyRepository; using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Server.Configuration; 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: an identity carrying no BrowseSubtrees pushed → BrowseChildren /// returns both children (unconstrained). That, not an absent identity, is production's /// unscoped case — with Authentication:Mode=ApiKey an absent identity /// now denies, which the isolated provider test pins. /// Phase 2: identity with BrowseSubtrees = ["NonExistent"] /// pushed via → the real /// threads the constraint into the lib /// service → BrowseChildren returns empty children. /// /// A task that represents the asynchronous operation. [Fact] public async Task BrowseChildren_BrowseSubtreesConstraintThroughHostWiring_FiltersChildren() { // Arrange: construct the REAL host seam — not stubs. GatewayRequestIdentityAccessor identityAccessor = new(); GatewayBrowseScopeProvider scopeProvider = new( identityAccessor, Options.Create(new GatewayOptions { Authentication = new Server.Configuration.AuthenticationOptions { Mode = AuthenticationMode.ApiKey, }, }), NullLogger.Instance); 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: an authenticated identity with no BrowseSubtrees constraint → browse is // unconstrained. Pump_001 and Valve_001 are both children of Line3 (GobjectId = 2). ApiKeyIdentity unscopedIdentity = new( KeyId: "test-key", KeyPrefix: "mxgw_test", DisplayName: "unscoped", Scopes: new HashSet(StringComparer.Ordinal) { GatewayScopes.MetadataRead }, Constraints: ApiKeyConstraints.Empty); using (identityAccessor.Push(unscopedIdentity)) { 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 { /// Gets the seeded cache entry passed to the stub's constructor. public GalaxyHierarchyCacheEntry Current { get; } = current; /// No-op: the stub's entry is always considered already loaded. /// Cancellation token. /// A task that represents the asynchronous operation. public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// No-op: the stub's entry is always considered already loaded. /// Cancellation token. /// A task that represents the asynchronous operation. 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 { /// Always throws: BrowseChildren never calls the repository directly. /// Cancellation token. /// Never returns; always throws. public Task TestConnectionAsync(CancellationToken ct = default) => throw new NotSupportedException("Not called during BrowseChildren."); /// Always throws: BrowseChildren never calls the repository directly. /// Cancellation token. /// Never returns; always throws. public Task GetLastDeployTimeAsync(CancellationToken ct = default) => throw new NotSupportedException("Not called during BrowseChildren."); /// Always throws: BrowseChildren never calls the repository directly. /// Cancellation token. /// Never returns; always throws. public Task> GetHierarchyAsync(CancellationToken ct = default) => throw new NotSupportedException("Not called during BrowseChildren."); /// Always throws: BrowseChildren never calls the repository directly. /// Cancellation token. /// Never returns; always throws. public Task> GetAttributesAsync(CancellationToken ct = default) => throw new NotSupportedException("Not called during BrowseChildren."); /// Always throws: BrowseChildren never calls the repository directly. /// Cancellation token. /// Never returns; always throws. public Task> GetAlarmAttributesAsync(CancellationToken ct = default) => throw new NotSupportedException("Not called during BrowseChildren."); } }