Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs
T
Joseph Doherty bd46ba1270 fix(test): drop removed logger arg from GalaxyRepositoryGrpcService test call sites; docs: STA phrasing
Remove the trailing NullLogger<GalaxyRepositoryGrpcService>.Instance argument
from all four CreateService/inline constructions in GalaxyRepositoryGrpcServiceTests
and GalaxyFilterInputSafetyTests, matching the now-4-param constructor after the
dead logger parameter was removed in 0032d2d. Also drop the now-unused
Microsoft.Extensions.Logging.Abstractions using from both files.

Rephrase the §5 STA blurb in docs/AlarmClientDiscovery.md: GatewayAlarmMonitor
routes polling *through* the worker's StaRuntime (which owns the STA pump) rather
than owning the pump itself.
2026-06-15 09:52:07 -04:00

487 lines
19 KiB
C#

using Grpc.Core;
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;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
public sealed class GalaxyRepositoryGrpcServiceTests
{
/// <summary>Verifies that DiscoverHierarchy returns the requested page and totals.</summary>
[Fact]
public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3)));
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = 2,
},
new TestServerCallContext());
Assert.Equal(2, reply.Objects.Count);
Assert.Equal("Object_001", reply.Objects[0].TagName);
Assert.Equal("Object_002", reply.Objects[1].TagName);
Assert.StartsWith("7:", reply.NextPageToken, StringComparison.Ordinal);
Assert.EndsWith(":2", reply.NextPageToken, StringComparison.Ordinal);
Assert.Equal(3, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy with a page token returns remaining objects.</summary>
[Fact]
public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3)));
DiscoverHierarchyReply firstPage = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = 2,
},
new TestServerCallContext());
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = 2,
PageToken = firstPage.NextPageToken,
},
new TestServerCallContext());
GalaxyObject item = Assert.Single(reply.Objects);
Assert.Equal("Object_003", item.TagName);
Assert.Equal("", reply.NextPageToken);
Assert.Equal(3, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy with invalid paging arguments returns InvalidArgument.</summary>
/// <param name="pageToken">The page token to test.</param>
/// <param name="pageSize">The page size to test.</param>
[Theory]
[InlineData("-1", 1)]
[InlineData("not-an-offset", 1)]
[InlineData("7:4", 1)]
[InlineData("6:2", 1)]
[InlineData("", -1)]
public async Task DiscoverHierarchy_WithInvalidPagingArguments_ReturnsInvalidArgument(
string pageToken,
int pageSize)
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3)));
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = pageSize,
PageToken = pageToken,
},
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
/// <summary>Verifies that DiscoverHierarchy with subtree root and depth filters descendants.</summary>
[Fact]
public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
RootContainedPath = "Area1/Line3",
MaxDepth = 1,
PageSize = 10,
},
new TestServerCallContext());
Assert.Equal(["Line3", "Pump_001", "Valve_001"], reply.Objects.Select(obj => obj.TagName));
Assert.Equal(3, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy applies server-side filters and omits attributes.</summary>
[Fact]
public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
RootTagName = "Area1",
TagNameGlob = "Pump_*",
AlarmBearingOnly = true,
HistorizedOnly = true,
IncludeAttributes = false,
PageSize = 10,
CategoryIds = { 10 },
TemplateChainContains = { "Pump" },
},
new TestServerCallContext());
GalaxyObject obj = Assert.Single(reply.Objects);
Assert.Equal("Pump_001", obj.TagName);
Assert.Empty(obj.Attributes);
Assert.Equal(1, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy with filtered paging returns post-filter total.</summary>
[Fact]
public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
DiscoverHierarchyReply first = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
RootGobjectId = 1,
PageSize = 1,
CategoryIds = { 10 },
},
new TestServerCallContext());
DiscoverHierarchyReply second = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
RootGobjectId = 1,
PageSize = 1,
PageToken = first.NextPageToken,
CategoryIds = { 10 },
},
new TestServerCallContext());
GalaxyObject firstObject = Assert.Single(first.Objects);
GalaxyObject secondObject = Assert.Single(second.Objects);
Assert.Equal(2, first.TotalObjectCount);
Assert.Equal(2, second.TotalObjectCount);
Assert.NotEqual(firstObject.TagName, secondObject.TagName);
}
/// <summary>Verifies that DiscoverHierarchy with mismatched filter token returns InvalidArgument.</summary>
[Fact]
public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
DiscoverHierarchyReply first = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = 1,
CategoryIds = { 10 },
},
new TestServerCallContext());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = 1,
PageToken = first.NextPageToken,
CategoryIds = { 11 },
},
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that DiscoverHierarchy with missing root returns NotFound.</summary>
[Fact]
public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
RootTagName = "Missing",
},
new TestServerCallContext()));
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
}
private static GalaxyRepositoryGrpcService CreateService(GalaxyHierarchyCacheEntry entry)
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
};
return new GalaxyRepositoryGrpcService(
new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options),
new StubGalaxyHierarchyCache(entry),
new GalaxyDeployNotifier(),
new GatewayRequestIdentityAccessor());
}
private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList<GalaxyObject> objects)
{
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 7,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown with
{
Status = DashboardGalaxyStatus.Healthy,
ObjectCount = objects.Count,
},
ObjectCount = objects.Count,
};
}
private static IReadOnlyList<GalaxyObject> CreateObjects(int count)
{
return Enumerable.Range(1, count)
.Select(index => new GalaxyObject
{
GobjectId = index,
TagName = $"Object_{index:000}",
BrowseName = $"Object_{index:000}",
})
.ToArray();
}
private static IReadOnlyList<GalaxyObject> CreateFilterObjects()
{
return
[
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,
TemplateChain = { "$Line", "$Base" },
},
new GalaxyObject
{
GobjectId = 3,
TagName = "Pump_001",
ContainedName = "Pump",
BrowseName = "Pump_001",
ParentGobjectId = 2,
CategoryId = 10,
TemplateChain = { "$Pump", "$Base" },
Attributes =
{
new GalaxyAttribute
{
AttributeName = "PV",
FullTagReference = "Pump_001.PV",
IsAlarm = true,
IsHistorized = true,
SecurityClassification = 2,
},
},
},
new GalaxyObject
{
GobjectId = 4,
TagName = "Valve_001",
ContainedName = "Valve",
BrowseName = "Valve_001",
ParentGobjectId = 2,
CategoryId = 11,
TemplateChain = { "$Valve" },
Attributes =
{
new GalaxyAttribute
{
AttributeName = "PV",
FullTagReference = "Valve_001.PV",
},
},
},
new GalaxyObject
{
GobjectId = 5,
TagName = "Other_001",
ContainedName = "Other",
BrowseName = "Other_001",
CategoryId = 10,
},
];
}
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>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());
// 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);
// 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);
}
}