test(gateway): restore end-to-end host browse-scope wiring coverage; strengthen snapshot summary assertion
Add GalaxyRepositoryHostWiringTests.BrowseChildren_BrowseSubtreesConstraintThroughHostWiring_FiltersChildren: constructs the real GatewayRequestIdentityAccessor + GatewayBrowseScopeProvider, passes the provider as IGalaxyBrowseScopeProvider to the lib GalaxyRepositoryGrpcService, and asserts two children (unconstrained) then empty (BrowseSubtrees=["NonExistent"]) — proving the full production authz-filtering chain is correctly wired. Strengthen DashboardSnapshotServiceTests.GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache: add Assert.Equal(2, TopTemplates.Count) and Assert.Contains($Area, InstanceCount==1) so the test guards the complete summary output, not just the $Pump entry.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end wiring tests for the host-side browse-scope authorisation chain:
|
||||
/// API-key constraints → <see cref="GatewayRequestIdentityAccessor.Push"/> →
|
||||
/// <see cref="GatewayBrowseScopeProvider.ResolveBrowseSubtrees"/> → lib
|
||||
/// <see cref="GalaxyRepositoryGrpcService"/> → filtered <c>BrowseChildren</c> reply.
|
||||
///
|
||||
/// These tests are NOT replaceable by the isolated
|
||||
/// <c>GatewayBrowseScopeProviderTests</c> (which tests the provider in isolation) or
|
||||
/// the lib's own test suite (which uses a fake <c>IGalaxyBrowseScopeProvider</c>).
|
||||
/// They verify that the gateway's concrete <see cref="GatewayBrowseScopeProvider"/> is
|
||||
/// correctly wired to the lib's <see cref="GalaxyRepositoryGrpcService"/> and that the
|
||||
/// filter actually propagates end-to-end into the returned child list.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryHostWiringTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the full production authz-filtering chain end-to-end.
|
||||
/// Phase 1: no identity pushed → <c>BrowseChildren</c> returns both children
|
||||
/// (unconstrained). Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
|
||||
/// pushed via <see cref="GatewayRequestIdentityAccessor"/> → the real
|
||||
/// <see cref="GatewayBrowseScopeProvider"/> threads the constraint into the lib
|
||||
/// service → <c>BrowseChildren</c> returns empty children.
|
||||
/// </summary>
|
||||
[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<string>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a minimal hierarchy: Area1 at root, Line3 under Area1,
|
||||
/// Pump_001 and Valve_001 under Line3.
|
||||
/// </summary>
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
/// <summary>Immediately-ready stub: Current returns the seeded entry, loads are instant.</summary>
|
||||
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub repository that throws on every method: <c>BrowseChildren</c> uses only
|
||||
/// the hierarchy cache, so the repository is never called during these tests.
|
||||
/// </summary>
|
||||
private sealed class StubGalaxyRepository : IGalaxyRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException("Not called during BrowseChildren.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException("Not called during BrowseChildren.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException("Not called during BrowseChildren.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException("Not called during BrowseChildren.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException("Not called during BrowseChildren.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user