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(3, snapshot.Galaxy.ObjectCount);
|
||||||
Assert.Equal(1, snapshot.Galaxy.AreaCount);
|
Assert.Equal(1, snapshot.Galaxy.AreaCount);
|
||||||
Assert.Equal(2, snapshot.Galaxy.AttributeCount);
|
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("$Pump", Assert.Single(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Pump").TemplateName);
|
||||||
Assert.Equal(2, snapshot.Galaxy.TopTemplates.First(t => t.TemplateName == "$Pump").InstanceCount);
|
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 == "UserDefined" && c.ObjectCount == 2);
|
||||||
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
|
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