grpc: implement BrowseChildren handler + metadata:read scope
This commit is contained in:
@@ -4,6 +4,7 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Grpc;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
|
||||
@@ -335,4 +336,155 @@ public sealed class GalaxyRepositoryGrpcServiceTests
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BrowseChildren returns root objects and the current cache sequence when called with no parent.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_RootCall_ReturnsRootsWithCacheSequence()
|
||||
{
|
||||
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
|
||||
|
||||
BrowseChildrenReply reply = await service.BrowseChildren(
|
||||
new BrowseChildrenRequest(),
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(2, reply.Children.Count);
|
||||
Assert.Equal("Area1", reply.Children[0].TagName);
|
||||
Assert.Equal("Other_001", reply.Children[1].TagName);
|
||||
Assert.Equal(7UL, reply.CacheSequence);
|
||||
Assert.Equal(2, reply.TotalChildCount);
|
||||
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BrowseChildren returns Unavailable when the cache's first load never completes.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_FirstLoadNotComplete_ReturnsUnavailable()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
{
|
||||
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
|
||||
};
|
||||
GalaxyRepositoryGrpcService service = new(
|
||||
new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options),
|
||||
new NeverLoadsHierarchyCache(),
|
||||
new GalaxyDeployNotifier(),
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
NullLogger<GalaxyRepositoryGrpcService>.Instance);
|
||||
|
||||
// No caller-supplied CT so WaitForCacheBootstrap exits via its 5s internal budget
|
||||
// (instead of re-throwing OperationCanceledException from the caller's CT). The
|
||||
// handler then sees Status=Unknown and returns Unavailable.
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.BrowseChildren(
|
||||
new BrowseChildrenRequest(),
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a page token bound to a stale cache sequence is rejected with InvalidArgument.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_StaleToken_ReturnsInvalidArgument()
|
||||
{
|
||||
GalaxyRepositoryGrpcService firstService = CreateService(CreateEntry(CreateFilterObjects()));
|
||||
BrowseChildrenReply firstReply = await firstService.BrowseChildren(
|
||||
new BrowseChildrenRequest { PageSize = 1 },
|
||||
new TestServerCallContext());
|
||||
Assert.False(string.IsNullOrEmpty(firstReply.NextPageToken));
|
||||
|
||||
GalaxyHierarchyCacheEntry newerEntry = CreateEntry(CreateFilterObjects()) with { Sequence = 8 };
|
||||
GalaxyRepositoryGrpcService secondService = CreateService(newerEntry);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await secondService.BrowseChildren(
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
PageSize = 1,
|
||||
PageToken = firstReply.NextPageToken,
|
||||
},
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Contains("stale", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that switching filters between paged BrowseChildren calls is rejected.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_FilterChangeBetweenPages_ReturnsInvalidArgument()
|
||||
{
|
||||
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
|
||||
BrowseChildrenReply firstReply = await service.BrowseChildren(
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
ParentGobjectId = 2,
|
||||
PageSize = 1,
|
||||
},
|
||||
new TestServerCallContext());
|
||||
Assert.False(string.IsNullOrEmpty(firstReply.NextPageToken));
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.BrowseChildren(
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
ParentGobjectId = 2,
|
||||
PageSize = 1,
|
||||
PageToken = firstReply.NextPageToken,
|
||||
TagNameGlob = "Pump*",
|
||||
},
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an ApiKeyIdentity browse-subtrees constraint that matches nothing produces an empty child list.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_BrowseSubtreesConstraint_FiltersChildren()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
{
|
||||
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
|
||||
};
|
||||
GatewayRequestIdentityAccessor identityAccessor = new();
|
||||
GalaxyRepositoryGrpcService service = new(
|
||||
new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options),
|
||||
new StubGalaxyHierarchyCache(CreateEntry(CreateFilterObjects())),
|
||||
new GalaxyDeployNotifier(),
|
||||
identityAccessor,
|
||||
NullLogger<GalaxyRepositoryGrpcService>.Instance);
|
||||
|
||||
// Sanity: with no identity pushed, both Pump and Valve come back under Line3 (id=2).
|
||||
BrowseChildrenReply unconstrained = await service.BrowseChildren(
|
||||
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
||||
new TestServerCallContext());
|
||||
Assert.Equal(2, unconstrained.Children.Count);
|
||||
|
||||
ApiKeyIdentity identity = new(
|
||||
KeyId: "test-key",
|
||||
KeyPrefix: "mxgw_test",
|
||||
DisplayName: "constraint-only",
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NeverLoadsHierarchyCache : IGalaxyHierarchyCache
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public GalaxyHierarchyCacheEntry Current { get; } =
|
||||
GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Unknown };
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) =>
|
||||
Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class GatewayGrpcScopeResolverTests
|
||||
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
|
||||
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
|
||||
[InlineData(typeof(WatchDeployEventsRequest), GatewayScopes.MetadataRead)]
|
||||
[InlineData(typeof(BrowseChildrenRequest), GatewayScopes.MetadataRead)]
|
||||
public void ResolveRequiredScope_KnownRpcRequest_ReturnsExpectedScope(
|
||||
Type requestType,
|
||||
string expectedScope)
|
||||
|
||||
Reference in New Issue
Block a user