rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx

Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.

External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
  MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths

Also fixes two tests that were not rename-related but became visible
while validating the rename:

- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
  gateway service correctly maps to RpcException(Cancelled) per gRPC
  convention was being misclassified as a stream fault. Added a sibling
  catch on RpcException with StatusCode.Cancelled.

- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
  and made it accept either a .git marker OR a .sln/.slnx next to src/
  so the worker-exe walker works in non-git working copies.

clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.

Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
  Tests: 472/472 pass
  Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
  IntegrationTests: 18/18 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 16:22:23 -04:00
parent 867bf18116
commit dc9c0c950c
491 changed files with 32854 additions and 8414 deletions
@@ -0,0 +1,523 @@
using System.Runtime.CompilerServices;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
public sealed class EventStreamServiceTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that events from the worker stream maintain their original sequence order.</summary>
[Fact]
public async Task StreamEventsAsync_YieldsEventsInWorkerOrder()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
FakeSessionManager sessionManager = new(session);
using GatewayMetrics metrics = new();
EventStreamService service = CreateService(sessionManager, metrics: metrics);
workerClient.Events.Add(CreateWorkerEvent(sequence: 10, MxEventFamily.OnDataChange));
workerClient.Events.Add(CreateWorkerEvent(sequence: 11, MxEventFamily.OnWriteComplete));
workerClient.CompleteAfterConfiguredEvents = true;
List<MxEvent> events = await CollectEventsAsync(service, session.SessionId);
Assert.Equal([10UL, 11UL], events.Select(mxEvent => mxEvent.WorkerSequence).ToArray());
Assert.Equal(MxEventFamily.OnDataChange, events[0].Family);
Assert.Equal(MxEventFamily.OnWriteComplete, events[1].Family);
Assert.Equal(1, metrics.GetSnapshot().StreamDisconnects);
}
/// <summary>Verifies that a second event subscriber is rejected when one is already active.</summary>
[Fact]
public async Task StreamEventsAsync_WhenSecondSubscriberStarts_RejectsClearly()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
EventStreamService service = CreateService(new FakeSessionManager(session));
using CancellationTokenSource firstSubscriberCancellation = new();
await using IAsyncEnumerator<MxEvent> firstSubscriber = service
.StreamEventsAsync(CreateRequest(session.SessionId), firstSubscriberCancellation.Token)
.GetAsyncEnumerator(firstSubscriberCancellation.Token);
Task<bool> firstMoveTask = firstSubscriber.MoveNextAsync().AsTask();
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 1);
await using IAsyncEnumerator<MxEvent> secondSubscriber = service
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
.GetAsyncEnumerator();
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await secondSubscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
Assert.Equal(SessionManagerErrorCode.EventSubscriberAlreadyActive, exception.ErrorCode);
await firstSubscriberCancellation.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await firstMoveTask.WaitAsync(TestTimeout));
await firstSubscriber.DisposeAsync();
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0);
}
/// <summary>Verifies that canceling an event stream detaches the subscriber cleanly.</summary>
[Fact]
public async Task StreamEventsAsync_WhenCanceled_DetachesSubscriber()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
EventStreamService service = CreateService(new FakeSessionManager(session));
using CancellationTokenSource cancellationTokenSource = new();
await using IAsyncEnumerator<MxEvent> subscriber = service
.StreamEventsAsync(CreateRequest(session.SessionId), cancellationTokenSource.Token)
.GetAsyncEnumerator(cancellationTokenSource.Token);
Task<bool> moveTask = subscriber.MoveNextAsync().AsTask();
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 1);
await cancellationTokenSource.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await moveTask.WaitAsync(TestTimeout));
await subscriber.DisposeAsync();
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0);
}
/// <summary>Verifies that disposing an event stream with buffered events resets the queue depth metric.</summary>
[Fact]
public async Task StreamEventsAsync_WhenDisposedWithBufferedEvents_ResetsStreamQueueDepth()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
using GatewayMetrics metrics = new();
EventStreamService service = CreateService(
new FakeSessionManager(session),
metrics,
queueCapacity: 8);
workerClient.Events.Add(CreateWorkerEvent(sequence: 1, MxEventFamily.OnDataChange));
workerClient.Events.Add(CreateWorkerEvent(sequence: 2, MxEventFamily.OnDataChange));
workerClient.Events.Add(CreateWorkerEvent(sequence: 3, MxEventFamily.OnDataChange));
workerClient.CompleteAfterConfiguredEvents = true;
await using IAsyncEnumerator<MxEvent> subscriber = service
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
.GetAsyncEnumerator();
Assert.True(await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth > 0);
await subscriber.DisposeAsync();
await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth == 0);
}
/// <summary>Verifies that queue depth metrics correctly track concurrent event streams across multiple sessions.</summary>
[Fact]
public async Task StreamEventsAsync_WithConcurrentStreams_TracksAggregateQueueDepth()
{
FakeWorkerClient firstWorkerClient = new();
FakeWorkerClient secondWorkerClient = new();
GatewaySession firstSession = CreateReadySession(firstWorkerClient, "session-events-1");
GatewaySession secondSession = CreateReadySession(secondWorkerClient, "session-events-2");
using GatewayMetrics metrics = new();
EventStreamService service = CreateService(
new FakeSessionManager(firstSession, secondSession),
metrics,
queueCapacity: 8);
for (ulong sequence = 1; sequence <= 3; sequence++)
{
firstWorkerClient.Events.Add(CreateWorkerEvent(sequence, MxEventFamily.OnDataChange));
secondWorkerClient.Events.Add(CreateWorkerEvent(sequence, MxEventFamily.OnDataChange));
}
firstWorkerClient.CompleteAfterConfiguredEvents = true;
secondWorkerClient.CompleteAfterConfiguredEvents = true;
await using IAsyncEnumerator<MxEvent> firstSubscriber = service
.StreamEventsAsync(CreateRequest(firstSession.SessionId), CancellationToken.None)
.GetAsyncEnumerator();
await using IAsyncEnumerator<MxEvent> secondSubscriber = service
.StreamEventsAsync(CreateRequest(secondSession.SessionId), CancellationToken.None)
.GetAsyncEnumerator();
Assert.True(await firstSubscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
Assert.True(await secondSubscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth == 4);
await firstSubscriber.DisposeAsync();
await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth == 2);
await secondSubscriber.DisposeAsync();
await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth == 0);
}
/// <summary>Verifies that event queue overflow faults the session and reports the overflow metric.</summary>
[Fact]
public async Task StreamEventsAsync_WhenStreamQueueOverflows_FaultsSessionAndReportsOverflow()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
using GatewayMetrics metrics = new();
EventStreamService service = CreateService(
new FakeSessionManager(session),
metrics,
queueCapacity: 1);
workerClient.Events.Add(CreateWorkerEvent(sequence: 1, MxEventFamily.OnDataChange));
workerClient.Events.Add(CreateWorkerEvent(sequence: 2, MxEventFamily.OnDataChange));
workerClient.Events.Add(CreateWorkerEvent(sequence: 3, MxEventFamily.OnDataChange));
workerClient.CompleteAfterConfiguredEvents = true;
await using IAsyncEnumerator<MxEvent> subscriber = service
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
.GetAsyncEnumerator();
Assert.True(await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
await WaitUntilAsync(() => session.State == SessionState.Faulted);
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
Assert.Equal(SessionManagerErrorCode.EventQueueOverflow, exception.ErrorCode);
Assert.Equal(SessionState.Faulted, session.State);
Assert.Equal(1, metrics.GetSnapshot().QueueOverflows);
Assert.Equal(1, metrics.GetSnapshot().Faults);
}
/// <summary>Verifies that the disconnect backpressure policy disconnects the subscriber without faulting the session.</summary>
[Fact]
public async Task StreamEventsAsync_WhenStreamQueueOverflowsWithDisconnectPolicy_LeavesSessionReady()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
using GatewayMetrics metrics = new();
EventStreamService service = CreateService(
new FakeSessionManager(session),
metrics,
queueCapacity: 1,
backpressurePolicy: EventBackpressurePolicy.DisconnectSubscriber);
workerClient.Events.Add(CreateWorkerEvent(sequence: 1, MxEventFamily.OnDataChange));
workerClient.Events.Add(CreateWorkerEvent(sequence: 2, MxEventFamily.OnDataChange));
workerClient.Events.Add(CreateWorkerEvent(sequence: 3, MxEventFamily.OnDataChange));
workerClient.CompleteAfterConfiguredEvents = true;
await using IAsyncEnumerator<MxEvent> subscriber = service
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
.GetAsyncEnumerator();
Assert.True(await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
Assert.Equal(SessionManagerErrorCode.EventQueueOverflow, exception.ErrorCode);
Assert.Equal(SessionState.Ready, session.State);
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(1, snapshot.QueueOverflows);
Assert.Equal(0, snapshot.Faults);
Assert.Equal(1, snapshot.StreamDisconnects);
}
/// <summary>Verifies that the event stream does not synthesize OperationComplete events from write completions.</summary>
[Fact]
public async Task StreamEventsAsync_DoesNotSynthesizeOperationComplete()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
EventStreamService service = CreateService(new FakeSessionManager(session));
workerClient.Events.Add(CreateWorkerEvent(sequence: 10, MxEventFamily.OnWriteComplete));
workerClient.CompleteAfterConfiguredEvents = true;
List<MxEvent> events = await CollectEventsAsync(service, session.SessionId);
MxEvent mxEvent = Assert.Single(events);
Assert.Equal(MxEventFamily.OnWriteComplete, mxEvent.Family);
Assert.DoesNotContain(events, candidate => candidate.Family == MxEventFamily.OperationComplete);
}
/// <summary>Verifies that a terminal fault from the worker event stream propagates and faults the session.</summary>
[Fact]
public async Task StreamEventsAsync_WhenWorkerEventStreamFaults_PropagatesTerminalFault()
{
FakeWorkerClient workerClient = new()
{
TerminalException = new WorkerClientException(
WorkerClientErrorCode.WorkerFaulted,
"worker terminal fault"),
};
GatewaySession session = CreateReadySession(workerClient);
using GatewayMetrics metrics = new();
EventStreamService service = CreateService(new FakeSessionManager(session), metrics);
await using IAsyncEnumerator<MxEvent> subscriber = service
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
.GetAsyncEnumerator();
WorkerClientException exception = await Assert.ThrowsAsync<WorkerClientException>(
async () => await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
Assert.Equal(WorkerClientErrorCode.WorkerFaulted, exception.ErrorCode);
Assert.Equal(SessionState.Faulted, session.State);
Assert.Equal(1, metrics.GetSnapshot().Faults);
}
private static EventStreamService CreateService(
FakeSessionManager sessionManager,
GatewayMetrics? metrics = null,
int queueCapacity = 8,
EventBackpressurePolicy backpressurePolicy = EventBackpressurePolicy.FailFast)
{
return new EventStreamService(
sessionManager,
Options.Create(new GatewayOptions
{
Events = new EventOptions
{
QueueCapacity = queueCapacity,
BackpressurePolicy = backpressurePolicy,
},
}),
new MxAccessGrpcMapper(),
metrics ?? new GatewayMetrics(),
NullLogger<EventStreamService>.Instance);
}
private static async Task<List<MxEvent>> CollectEventsAsync(
EventStreamService service,
string sessionId)
{
List<MxEvent> events = [];
await foreach (MxEvent mxEvent in service
.StreamEventsAsync(CreateRequest(sessionId), CancellationToken.None)
.WithCancellation(CancellationToken.None))
{
events.Add(mxEvent);
}
return events;
}
private static StreamEventsRequest CreateRequest(string sessionId)
{
return new StreamEventsRequest
{
SessionId = sessionId,
};
}
private static GatewaySession CreateReadySession(
FakeWorkerClient workerClient,
string sessionId = "session-events")
{
GatewaySession session = new(
sessionId,
GatewayContractInfo.DefaultBackendName,
"pipe",
"nonce",
"client",
"client-session",
"client-correlation",
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(10),
DateTimeOffset.UtcNow);
session.AttachWorkerClient(workerClient);
session.MarkReady();
return session;
}
private static WorkerEvent CreateWorkerEvent(
ulong sequence,
MxEventFamily family)
{
MxEvent mxEvent = new()
{
SessionId = "session-events",
Family = family,
WorkerSequence = sequence,
};
switch (family)
{
case MxEventFamily.OnDataChange:
mxEvent.OnDataChange = new OnDataChangeEvent();
break;
case MxEventFamily.OnWriteComplete:
mxEvent.OnWriteComplete = new OnWriteCompleteEvent();
break;
case MxEventFamily.OperationComplete:
mxEvent.OperationComplete = new OperationCompleteEvent();
break;
case MxEventFamily.OnBufferedDataChange:
mxEvent.OnBufferedDataChange = new OnBufferedDataChangeEvent();
break;
}
return new WorkerEvent
{
Event = mxEvent,
};
}
private static async Task WaitUntilAsync(Func<bool> predicate)
{
using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
while (!predicate())
{
await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationTokenSource.Token);
}
}
/// <summary>Fake session manager for testing event streams.</summary>
private sealed class FakeSessionManager : ISessionManager
{
private readonly IReadOnlyDictionary<string, GatewaySession> _sessions;
/// <summary>Initializes a new instance of the FakeSessionManager.</summary>
/// <param name="sessions">Sessions to manage.</param>
public FakeSessionManager(params GatewaySession[] sessions)
{
_sessions = sessions.ToDictionary(session => session.SessionId, StringComparer.Ordinal);
}
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
CancellationToken cancellationToken)
{
return Task.FromResult(_sessions.Values.First());
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
out GatewaySession gatewaySession)
{
return _sessions.TryGetValue(sessionId, out gatewaySession!);
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken)
{
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
{
return _sessions[sessionId].ReadEventsAsync(cancellationToken);
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
{
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
{
return Task.FromResult(0);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
/// <summary>Fake worker client for testing event streams.</summary>
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the list of queued worker events.</summary>
public List<WorkerEvent> Events { get; } = [];
/// <summary>Gets or sets whether to complete the event stream after configured events are yielded.</summary>
public bool CompleteAfterConfiguredEvents { get; set; }
/// <summary>Gets or sets an optional exception to throw as a terminal event stream fault.</summary>
public Exception? TerminalException { get; init; }
/// <inheritdoc />
public string SessionId { get; } = "session-events";
/// <inheritdoc />
public int? ProcessId { get; } = 4321;
/// <inheritdoc />
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken)
{
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (WorkerEvent workerEvent in Events)
{
cancellationToken.ThrowIfCancellationRequested();
yield return workerEvent;
}
if (TerminalException is not null)
{
throw TerminalException;
}
if (CompleteAfterConfiguredEvents)
{
yield break;
}
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
/// <inheritdoc />
public Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
{
State = WorkerClientState.Closed;
return Task.CompletedTask;
}
/// <inheritdoc />
public void Kill(string reason)
{
State = WorkerClientState.Faulted;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,325 @@
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
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.Authorization;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
public sealed class GalaxyRepositoryGrpcServiceTests
{
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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(),
NullLogger<GalaxyRepositoryGrpcService>.Instance);
}
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
{
public GalaxyHierarchyCacheEntry Current { get; } = current;
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -0,0 +1,974 @@
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
/// <summary>
/// Tests for Server-021. <c>MxAccessGatewayService.ApplyConstraintsAsync</c> and
/// the <c>BulkConstraintPlan</c> / <c>ReadBulkConstraintPlan</c> /
/// <c>WriteBulkConstraintPlan</c> / <c>SubscribeBulkConstraintPlan</c> reply-merge
/// logic was previously exercised only with an allow-all enforcer, so denial
/// filtering, the no-allowed-items short-circuit, and the index-ordered
/// denied/allowed interleave were dead code at test time. The fixtures below
/// inject a <see cref="PredicateConstraintEnforcer"/> that denies a subset of
/// tags or handles, and assert the post-merge reply contents and that the
/// session manager is (or is not) invoked.
/// </summary>
public sealed class MxAccessGatewayServiceConstraintTests
{
private const string SessionId = "session-constraint";
// === SubscribeBulk family: AddItemBulk / SubscribeBulk / AdviseItemBulk ===
/// <summary>
/// <c>AddItemBulk</c> with a mix of allowed and denied tags must invoke the
/// worker once with only the allowed tags, then splice the denied entries
/// back into the reply at their original indices.
/// </summary>
[Fact]
public async Task Invoke_AddItemBulk_WithMixedDenials_InterleavesDeniedAndAllowedInOriginalIndexOrder()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyTag = tag => tag == "Tank01.Locked" || tag == "Tank03.Secret",
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.AddItemBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
AddItemBulk = new BulkSubscribeReply
{
Results =
{
// Worker only sees the two allowed tags — Tank02.Open at original
// index 1 and Tank04.Public at original index 3.
new SubscribeResult { ServerHandle = 7, TagAddress = "Tank02.Open", ItemHandle = 102, WasSuccessful = true },
new SubscribeResult { ServerHandle = 7, TagAddress = "Tank04.Public", ItemHandle = 104, WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateAddItemBulkRequest(7, ["Tank01.Locked", "Tank02.Open", "Tank03.Secret", "Tank04.Public"]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
// Worker saw only the allowed subset, in original order, with denied entries dropped.
AddItemBulkCommand forwardedCommand = sessionManager.LastWorkerCommand!.Command.AddItemBulk;
Assert.Equal(["Tank02.Open", "Tank04.Public"], forwardedCommand.TagAddresses);
// Final reply preserves the original 4-entry index order, with denied entries
// at index 0 and 2 and worker-allowed entries at index 1 and 3.
BulkSubscribeReply merged = reply.AddItemBulk;
Assert.Equal(4, merged.Results.Count);
Assert.False(merged.Results[0].WasSuccessful);
Assert.Equal("Tank01.Locked", merged.Results[0].TagAddress);
Assert.Contains("Tank01.Locked", merged.Results[0].ErrorMessage, StringComparison.Ordinal);
Assert.True(merged.Results[1].WasSuccessful);
Assert.Equal("Tank02.Open", merged.Results[1].TagAddress);
Assert.Equal(102, merged.Results[1].ItemHandle);
Assert.False(merged.Results[2].WasSuccessful);
Assert.Equal("Tank03.Secret", merged.Results[2].TagAddress);
Assert.True(merged.Results[3].WasSuccessful);
Assert.Equal("Tank04.Public", merged.Results[3].TagAddress);
Assert.Equal(104, merged.Results[3].ItemHandle);
// Both denied tags recorded.
Assert.Equal(2, enforcer.RecordedDenials.Count);
}
/// <summary>
/// <c>SubscribeBulk</c> when every tag is denied must short-circuit
/// <see cref="BulkConstraintPlan.HasAllowedItems"/> false, return the
/// denied-only reply, and never call the session manager.
/// </summary>
[Fact]
public async Task Invoke_SubscribeBulk_WhenAllTagsDenied_DoesNotCallWorkerAndReturnsDeniedReply()
{
PredicateConstraintEnforcer enforcer = new() { DenyTag = _ => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateSubscribeBulkRequest(7, ["A", "B", "C"]),
new TestServerCallContext());
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Equal(3, reply.SubscribeBulk.Results.Count);
Assert.All(reply.SubscribeBulk.Results, r => Assert.False(r.WasSuccessful));
Assert.Equal(["A", "B", "C"], reply.SubscribeBulk.Results.Select(r => r.TagAddress));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
}
/// <summary>
/// <c>AdviseItemBulk</c> takes handle inputs (not tags) and routes through
/// <c>FilterHandleBulkAsync</c> against <c>CheckReadHandleAsync</c>. Partial
/// denial must still produce a merged-by-index <c>BulkSubscribeReply</c>.
/// </summary>
[Fact]
public async Task Invoke_AdviseItemBulk_WithMixedHandleDenials_MergesDeniedIntoReply()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyReadHandle = (_, itemHandle) => itemHandle == 502,
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.AdviseItemBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
AdviseItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 7, ItemHandle = 501, WasSuccessful = true },
new SubscribeResult { ServerHandle = 7, ItemHandle = 503, WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateAdviseItemBulkRequest(7, [501, 502, 503]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
Assert.Equal([501, 503], sessionManager.LastWorkerCommand!.Command.AdviseItemBulk.ItemHandles);
BulkSubscribeReply merged = reply.AdviseItemBulk;
Assert.Equal(3, merged.Results.Count);
Assert.True(merged.Results[0].WasSuccessful);
Assert.Equal(501, merged.Results[0].ItemHandle);
Assert.False(merged.Results[1].WasSuccessful);
Assert.Equal(502, merged.Results[1].ItemHandle);
Assert.True(merged.Results[2].WasSuccessful);
Assert.Equal(503, merged.Results[2].ItemHandle);
}
/// <summary>
/// <c>SubscribeBulk</c> with an allow-all enforcer must leave the worker reply
/// unchanged — the constraint plan is null and no merge occurs. Regression
/// guard against accidentally engaging the merge path for the common case.
/// </summary>
[Fact]
public async Task Invoke_SubscribeBulk_WithAllowAllEnforcer_PassesThroughUnchanged()
{
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.SubscribeBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
SubscribeBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 7, TagAddress = "A", ItemHandle = 1, WasSuccessful = true },
new SubscribeResult { ServerHandle = 7, TagAddress = "B", ItemHandle = 2, WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager);
MxCommandReply reply = await service.Invoke(
CreateSubscribeBulkRequest(7, ["A", "B"]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
Assert.Equal(["A", "B"], sessionManager.LastWorkerCommand!.Command.SubscribeBulk.TagAddresses);
// Reply identical to worker reply — no synthetic denial rows added.
Assert.Equal(2, reply.SubscribeBulk.Results.Count);
Assert.All(reply.SubscribeBulk.Results, r => Assert.True(r.WasSuccessful));
}
// === ReadBulk family ===
/// <summary>
/// <c>ReadBulk</c> with a mix of allowed and denied tags merges denied entries
/// into the <c>BulkReadReply</c> in original-index order, distinguishable from
/// the SubscribeBulk family because the reply slot is
/// <c>BulkReadReply</c>, not <c>BulkSubscribeReply</c>.
/// </summary>
[Fact]
public async Task Invoke_ReadBulk_WithMixedDenials_MergesDeniedBulkReadResults()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyTag = tag => tag == "Secret.Tag",
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.ReadBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
ReadBulk = new BulkReadReply
{
Results =
{
new BulkReadResult { ServerHandle = 7, TagAddress = "Public.A", WasSuccessful = true },
new BulkReadResult { ServerHandle = 7, TagAddress = "Public.B", WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateReadBulkRequest(7, ["Public.A", "Secret.Tag", "Public.B"]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
Assert.Equal(["Public.A", "Public.B"], sessionManager.LastWorkerCommand!.Command.ReadBulk.TagAddresses);
BulkReadReply merged = reply.ReadBulk;
Assert.Equal(3, merged.Results.Count);
Assert.True(merged.Results[0].WasSuccessful);
Assert.False(merged.Results[1].WasSuccessful);
Assert.Equal("Secret.Tag", merged.Results[1].TagAddress);
Assert.True(merged.Results[2].WasSuccessful);
}
/// <summary>
/// <c>ReadBulk</c> with all tags denied must short-circuit and produce a
/// denied-only <c>BulkReadReply</c> — verifying
/// <see cref="MxAccessGatewayService"/>'s <c>ReadBulkConstraintPlan</c>
/// <c>CreateDeniedReply</c> path.
/// </summary>
[Fact]
public async Task Invoke_ReadBulk_WhenAllTagsDenied_ShortCircuitsWithDeniedOnlyReply()
{
PredicateConstraintEnforcer enforcer = new() { DenyTag = _ => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateReadBulkRequest(7, ["X", "Y"]),
new TestServerCallContext());
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Equal(2, reply.ReadBulk.Results.Count);
Assert.All(reply.ReadBulk.Results, r => Assert.False(r.WasSuccessful));
Assert.Equal(MxCommandKind.ReadBulk, reply.Kind);
}
// === WriteBulk family: WriteBulk / Write2Bulk / WriteSecuredBulk / WriteSecured2Bulk ===
/// <summary>
/// <c>WriteBulk</c> with one denied handle must drop that entry from the
/// forwarded command and splice a denied <c>BulkWriteResult</c> back in at
/// the original index.
/// </summary>
[Fact]
public async Task Invoke_WriteBulk_WithDeniedHandle_DropsEntryFromWorkerCallAndMergesDenialIntoReply()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyWriteHandle = (_, itemHandle) => itemHandle == 902,
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.WriteBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
WriteBulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 7, ItemHandle = 903, WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateWriteBulkRequest(7, [901, 902, 903]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
// 902 dropped from forwarded entries; only 901 and 903 reach the worker.
WriteBulkCommand forwarded = sessionManager.LastWorkerCommand!.Command.WriteBulk;
Assert.Equal([901, 903], forwarded.Entries.Select(e => e.ItemHandle));
BulkWriteReply merged = reply.WriteBulk;
Assert.Equal(3, merged.Results.Count);
Assert.True(merged.Results[0].WasSuccessful);
Assert.Equal(901, merged.Results[0].ItemHandle);
Assert.False(merged.Results[1].WasSuccessful);
Assert.Equal(902, merged.Results[1].ItemHandle);
Assert.True(merged.Results[2].WasSuccessful);
Assert.Equal(903, merged.Results[2].ItemHandle);
}
/// <summary>
/// <c>WriteSecuredBulk</c> exercises a different <c>ReplaceWriteBulkEntries</c>
/// switch arm than plain <c>WriteBulk</c>. The merge logic is shared, so a
/// full denial here is enough to prove the secured-bulk routing.
/// </summary>
[Fact]
public async Task Invoke_WriteSecuredBulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply()
{
PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateWriteSecuredBulkRequest(7, [10, 11]),
new TestServerCallContext());
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Equal(MxCommandKind.WriteSecuredBulk, reply.Kind);
Assert.Equal(2, reply.WriteSecuredBulk.Results.Count);
Assert.All(reply.WriteSecuredBulk.Results, r => Assert.False(r.WasSuccessful));
}
/// <summary>
/// Tests-020: <c>Write2Bulk</c> takes the third <c>GetPayload</c>/<c>SetPayload</c>
/// switch arm in <c>WriteBulkConstraintPlan</c>. The merge logic is shared with
/// <c>WriteBulk</c>, but a full denial through the <c>CreateDeniedReply</c> path
/// proves the <c>Write2Bulk</c> arm of the per-kind <c>SetPayload</c> switch fires
/// (and not, say, <c>WriteBulk</c> by mistake) — guarding against a refactor that
/// drops or misroutes the <c>Write2Bulk</c> case.
/// </summary>
[Fact]
public async Task Invoke_Write2Bulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply()
{
PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateWrite2BulkRequest(7, [10, 11]),
new TestServerCallContext());
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Equal(MxCommandKind.Write2Bulk, reply.Kind);
Assert.Equal(2, reply.Write2Bulk.Results.Count);
Assert.All(reply.Write2Bulk.Results, r => Assert.False(r.WasSuccessful));
// Sibling reply slots must remain empty — pin the SetPayload arm fired
// for Write2Bulk and not for one of the other three Write*Bulk kinds.
Assert.Empty(reply.WriteBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField<BulkWriteResult>());
Assert.Empty(reply.WriteSecuredBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField<BulkWriteResult>());
Assert.Empty(reply.WriteSecured2Bulk?.Results ?? new Google.Protobuf.Collections.RepeatedField<BulkWriteResult>());
}
/// <summary>
/// Tests-020: <c>WriteSecured2Bulk</c> takes the fourth <c>GetPayload</c>/<c>SetPayload</c>
/// switch arm in <c>WriteBulkConstraintPlan</c>. Same reasoning as
/// <c>Write2Bulk</c> — assert the <c>WriteSecured2Bulk</c> reply slot is populated
/// to prove that arm of the switch fires.
/// </summary>
[Fact]
public async Task Invoke_WriteSecured2Bulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply()
{
PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateWriteSecured2BulkRequest(7, [10, 11]),
new TestServerCallContext());
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Equal(MxCommandKind.WriteSecured2Bulk, reply.Kind);
Assert.Equal(2, reply.WriteSecured2Bulk.Results.Count);
Assert.All(reply.WriteSecured2Bulk.Results, r => Assert.False(r.WasSuccessful));
// Sibling reply slots must remain empty — pin the SetPayload arm fired
// for WriteSecured2Bulk and not for one of the other three Write*Bulk kinds.
Assert.Empty(reply.WriteBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField<BulkWriteResult>());
Assert.Empty(reply.Write2Bulk?.Results ?? new Google.Protobuf.Collections.RepeatedField<BulkWriteResult>());
Assert.Empty(reply.WriteSecuredBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField<BulkWriteResult>());
}
// === Worker reply-count divergence (Tests-024) ===
/// <summary>
/// Tests-024: <c>WriteBulkConstraintPlan.MergeDeniedInto</c> dequeues from
/// <c>allowedResults</c> per non-denied slot via <c>Queue.TryDequeue</c>,
/// which silently returns <c>false</c> when the queue is empty. Pin the
/// observable behaviour when the worker returns FEWER allowed results than
/// the gateway forwarded: the merged reply is truncated — denied entries
/// keep their slots, but the trailing allowed slot for which no worker
/// result arrived is dropped (no synthetic failure result is fabricated).
/// This fixture makes that "silent truncate" behaviour explicit so a future
/// change either fills the gap with a synthetic failure or fails this test.
/// </summary>
[Fact]
public async Task Invoke_WriteBulk_WhenWorkerReturnsFewerResultsThanAllowed_MergedReplyIsTruncated()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyWriteHandle = (_, itemHandle) => itemHandle == 902,
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
// Gateway forwards 2 allowed handles (901, 903) but the worker returns only
// 1 result. The merge logic should keep denied entry 902 at index 1, place
// the single worker result at index 0, and leave index 2 empty (truncate).
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.WriteBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
WriteBulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateWriteBulkRequest(7, [901, 902, 903]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
BulkWriteReply merged = reply.WriteBulk;
// Current behaviour: the merged reply is shorter than OriginalCount when
// the worker under-supplies. Two slots survive — the worker result at
// index 0 and the denied entry at index 1 — and the trailing slot is
// silently dropped via Queue.TryDequeue returning false.
Assert.Equal(2, merged.Results.Count);
Assert.True(merged.Results[0].WasSuccessful);
Assert.Equal(901, merged.Results[0].ItemHandle);
Assert.False(merged.Results[1].WasSuccessful);
Assert.Equal(902, merged.Results[1].ItemHandle);
}
/// <summary>
/// Tests-024: when the worker returns MORE allowed results than the
/// gateway forwarded, the extras must be silently ignored — the merged
/// reply length stays at <c>OriginalCount</c>. This pins the
/// <c>for index &lt; OriginalCount</c> loop bound so a regression that
/// accidentally surfaces extras as trailing results is caught.
/// </summary>
[Fact]
public async Task Invoke_WriteBulk_WhenWorkerReturnsExtraResults_IgnoresExtras()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyWriteHandle = (_, itemHandle) => itemHandle == 902,
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
// Gateway forwards 2 allowed handles (901, 903) but the worker returns 4.
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.WriteBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
WriteBulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 7, ItemHandle = 903, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 7, ItemHandle = 999, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 7, ItemHandle = 1000, WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateWriteBulkRequest(7, [901, 902, 903]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
BulkWriteReply merged = reply.WriteBulk;
// Merged reply length stays at OriginalCount (3); the two extra worker
// results (item handles 999, 1000) are silently discarded by the
// OriginalCount-bounded loop.
Assert.Equal(3, merged.Results.Count);
Assert.Equal(901, merged.Results[0].ItemHandle);
Assert.True(merged.Results[0].WasSuccessful);
Assert.Equal(902, merged.Results[1].ItemHandle);
Assert.False(merged.Results[1].WasSuccessful);
Assert.Equal(903, merged.Results[2].ItemHandle);
Assert.True(merged.Results[2].WasSuccessful);
Assert.DoesNotContain(merged.Results, r => r.ItemHandle == 999);
Assert.DoesNotContain(merged.Results, r => r.ItemHandle == 1000);
}
// === Unary write-handle enforcement (EnforceWriteHandleAsync) ===
/// <summary>
/// Unary <c>Write</c> against a denied (server, item) handle must surface
/// <see cref="StatusCode.PermissionDenied"/> via <c>EnforceWriteHandleAsync</c>
/// and never reach the session manager.
/// </summary>
[Fact]
public async Task Invoke_Write_WithDeniedHandle_ThrowsPermissionDeniedAndDoesNotCallWorker()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyWriteHandle = (serverHandle, itemHandle) => serverHandle == 7 && itemHandle == 42,
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.Invoke(
CreateWriteRequest(serverHandle: 7, itemHandle: 42),
new TestServerCallContext()));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Single(enforcer.RecordedDenials);
Assert.Equal("42", enforcer.RecordedDenials[0].Target);
}
/// <summary>
/// Unary <c>WriteSecured</c> against a denied handle takes the same enforce path
/// and rejects identically — proving the four-arm switch in
/// <c>ApplyConstraintsAsync</c> (Write/Write2/WriteSecured/WriteSecured2) is
/// reachable for at least one of the secured kinds.
/// </summary>
[Fact]
public async Task Invoke_WriteSecured_WithDeniedHandle_ThrowsPermissionDenied()
{
PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.Invoke(
CreateWriteSecuredRequest(serverHandle: 7, itemHandle: 42),
new TestServerCallContext()));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Equal(0, sessionManager.InvokeCount);
}
// === Unary read-tag enforcement (EnforceReadTagAsync via AddItem) ===
/// <summary>
/// Unary <c>AddItem</c> against a denied tag must surface
/// <see cref="StatusCode.PermissionDenied"/> via <c>EnforceReadTagAsync</c>
/// and never reach the session manager.
/// </summary>
[Fact]
public async Task Invoke_AddItem_WithDeniedTag_ThrowsPermissionDeniedAndDoesNotCallWorker()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyTag = tag => tag == "Secret.Tag",
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.Invoke(
CreateAddItemRequest(serverHandle: 7, tagAddress: "Secret.Tag"),
new TestServerCallContext()));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Single(enforcer.RecordedDenials);
Assert.Equal("Secret.Tag", enforcer.RecordedDenials[0].Target);
}
// === Helpers ===
private static MxAccessGatewayService CreateService(
FakeSessionManager sessionManager,
IConstraintEnforcer? constraintEnforcer = null)
{
return new MxAccessGatewayService(
sessionManager,
new GatewayRequestIdentityAccessor(),
constraintEnforcer ?? new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new FakeEventStreamService(sessionManager),
new GatewayMetrics(),
NullLogger<MxAccessGatewayService>.Instance,
new FakeGatewayAlarmService());
}
private static FakeSessionManager CreateSessionManagerWithSeed()
{
FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true };
sessionManager.SeedSession(CreateSession(SessionId));
return sessionManager;
}
private static GatewaySession CreateSession(string sessionId)
{
GatewaySession session = new(
sessionId,
GatewayContractInfo.DefaultBackendName,
"pipe",
"nonce",
"Operator Key",
"operator-session",
"client-correlation",
TimeSpan.FromSeconds(7),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(10),
DateTimeOffset.UtcNow);
session.AttachWorkerClient(new FakeWorkerClient());
session.MarkReady();
return session;
}
private static MxCommandRequest CreateAddItemBulkRequest(int serverHandle, IReadOnlyList<string> tags)
{
AddItemBulkCommand cmd = new() { ServerHandle = serverHandle };
cmd.TagAddresses.Add(tags);
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.AddItemBulk, AddItemBulk = cmd },
};
}
private static MxCommandRequest CreateSubscribeBulkRequest(int serverHandle, IReadOnlyList<string> tags)
{
SubscribeBulkCommand cmd = new() { ServerHandle = serverHandle };
cmd.TagAddresses.Add(tags);
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.SubscribeBulk, SubscribeBulk = cmd },
};
}
private static MxCommandRequest CreateAdviseItemBulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
{
AdviseItemBulkCommand cmd = new() { ServerHandle = serverHandle };
cmd.ItemHandles.Add(itemHandles);
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.AdviseItemBulk, AdviseItemBulk = cmd },
};
}
private static MxCommandRequest CreateReadBulkRequest(int serverHandle, IReadOnlyList<string> tags)
{
ReadBulkCommand cmd = new() { ServerHandle = serverHandle, TimeoutMs = 1000 };
cmd.TagAddresses.Add(tags);
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.ReadBulk, ReadBulk = cmd },
};
}
private static MxCommandRequest CreateWriteBulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
{
WriteBulkCommand cmd = new() { ServerHandle = serverHandle };
foreach (int handle in itemHandles)
{
cmd.Entries.Add(new WriteBulkEntry { ItemHandle = handle, Value = new MxValue { StringValue = "v" } });
}
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.WriteBulk, WriteBulk = cmd },
};
}
private static MxCommandRequest CreateWriteSecuredBulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
{
WriteSecuredBulkCommand cmd = new() { ServerHandle = serverHandle };
foreach (int handle in itemHandles)
{
cmd.Entries.Add(new WriteSecuredBulkEntry
{
ItemHandle = handle,
CurrentUserId = 1,
VerifierUserId = 2,
Value = new MxValue { StringValue = "v" },
});
}
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.WriteSecuredBulk, WriteSecuredBulk = cmd },
};
}
private static MxCommandRequest CreateWrite2BulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
{
Write2BulkCommand cmd = new() { ServerHandle = serverHandle };
foreach (int handle in itemHandles)
{
cmd.Entries.Add(new Write2BulkEntry
{
ItemHandle = handle,
Value = new MxValue { StringValue = "v" },
TimestampValue = new MxValue { Int64Value = 1234567890L },
});
}
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.Write2Bulk, Write2Bulk = cmd },
};
}
private static MxCommandRequest CreateWriteSecured2BulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
{
WriteSecured2BulkCommand cmd = new() { ServerHandle = serverHandle };
foreach (int handle in itemHandles)
{
cmd.Entries.Add(new WriteSecured2BulkEntry
{
ItemHandle = handle,
CurrentUserId = 1,
VerifierUserId = 2,
Value = new MxValue { StringValue = "v" },
TimestampValue = new MxValue { Int64Value = 1234567890L },
});
}
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.WriteSecured2Bulk, WriteSecured2Bulk = cmd },
};
}
private static MxCommandRequest CreateWriteRequest(int serverHandle, int itemHandle)
{
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand
{
Kind = MxCommandKind.Write,
Write = new WriteCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
Value = new MxValue { StringValue = "v" },
},
},
};
}
private static MxCommandRequest CreateWriteSecuredRequest(int serverHandle, int itemHandle)
{
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecured,
WriteSecured = new WriteSecuredCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
CurrentUserId = 1,
VerifierUserId = 2,
Value = new MxValue { StringValue = "v" },
},
},
};
}
private static MxCommandRequest CreateAddItemRequest(int serverHandle, string tagAddress)
{
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = serverHandle,
ItemDefinition = tagAddress,
},
},
};
}
// FakeSessionManager / FakeEventStreamService / FakeWorkerClient mirror the
// implementations in MxAccessGatewayServiceTests; the duplication is intentional
// so the constraint tests are self-contained and changes to the existing fakes
// don't accidentally couple the two suites.
private sealed class FakeSessionManager : ISessionManager
{
private readonly Dictionary<string, GatewaySession> seededSessions = new(StringComparer.Ordinal);
public bool ResolveOnlySeededSessions { get; init; }
public WorkerCommand? LastWorkerCommand { get; private set; }
public int InvokeCount { get; private set; }
public WorkerCommandReply InvokeReply { get; set; } = new()
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.Ping,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
},
};
public List<WorkerEvent> Events { get; } = [];
public void SeedSession(GatewaySession session) => seededSessions[session.SessionId] = session;
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
CancellationToken cancellationToken) =>
Task.FromResult(seededSessions.Values.First());
public bool TryGetSession(string sessionId, out GatewaySession session)
{
if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded))
{
session = seeded;
return true;
}
if (ResolveOnlySeededSessions)
{
session = null!;
return false;
}
session = CreateFallbackSession(sessionId);
return true;
}
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken)
{
InvokeCount++;
LastWorkerCommand = command;
return Task.FromResult(InvokeReply);
}
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (WorkerEvent ev in Events)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return ev;
}
}
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private static GatewaySession CreateFallbackSession(string sessionId)
{
GatewaySession session = new(
sessionId,
GatewayContractInfo.DefaultBackendName,
"pipe",
"nonce",
"Operator Key",
"operator-session",
"client-correlation",
TimeSpan.FromSeconds(7),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(10),
DateTimeOffset.UtcNow);
session.AttachWorkerClient(new FakeWorkerClient());
session.MarkReady();
return session;
}
}
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
{
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (WorkerEvent ev in sessionManager.Events)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return ev.Event;
}
}
}
private sealed class FakeWorkerClient : IWorkerClient
{
public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId;
public int? ProcessId { get; } = 1234;
public WorkerClientState State { get; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
public void Kill(string reason)
{
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,636 @@
using System.Diagnostics.Metrics;
using System.Runtime.CompilerServices;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
public sealed class MxAccessGatewayServiceTests
{
/// <summary>Verifies that OpenSession returns correct session details for a valid request.</summary>
[Fact]
public async Task OpenSession_WithValidRequest_ReturnsSessionDetails()
{
GatewayRequestIdentityAccessor identityAccessor = new();
FakeSessionManager sessionManager = new()
{
OpenSessionResult = CreateSession("session-1", processId: 4321),
};
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
using IDisposable identityScope = identityAccessor.Push(CreateIdentity());
OpenSessionReply reply = await service.OpenSession(
new OpenSessionRequest
{
ClientSessionName = "operator-session",
CommandTimeout = Duration.FromTimeSpan(TimeSpan.FromSeconds(7)),
},
new TestServerCallContext());
Assert.Equal("session-1", reply.SessionId);
Assert.Equal(GatewayContractInfo.DefaultBackendName, reply.BackendName);
Assert.Equal(4321, reply.WorkerProcessId);
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, reply.WorkerProtocolVersion);
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, reply.GatewayProtocolVersion);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Contains("unary-invoke", reply.Capabilities);
Assert.Equal("Operator Key", sessionManager.LastClientIdentity);
Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName);
}
/// <summary>
/// Verifies that Invoke maps a genuinely missing session to NotFound via the
/// service's own <c>ResolveSession</c> lookup. No <c>InvokeException</c> is
/// injected — <see cref="FakeSessionManager.ResolveOnlySeededSessions"/> makes
/// <c>TryGetSession</c> return false, so this test fails if the service drops
/// its missing-session check rather than passing for the wrong reason.
/// </summary>
[Fact]
public async Task Invoke_WhenSessionMissing_ThrowsNotFound()
{
FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true };
MxAccessGatewayService service = CreateService(sessionManager);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.Invoke(
CreatePingRequest("session-missing"),
new TestServerCallContext()));
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal);
// The service must reject before delegating to the session manager.
Assert.Equal(0, sessionManager.InvokeCount);
}
/// <summary>
/// Verifies that Invoke resolves a session that was seeded into the session
/// manager when <see cref="FakeSessionManager.ResolveOnlySeededSessions"/> is on,
/// confirming the missing-session test above is gated on a real lookup.
/// </summary>
[Fact]
public async Task Invoke_WhenSessionSeeded_ResolvesAndInvokes()
{
FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true };
sessionManager.SeedSession(CreateSession("session-1", processId: 1234));
MxAccessGatewayService service = CreateService(sessionManager);
MxCommandReply reply = await service.Invoke(
CreatePingRequest("session-1"),
new TestServerCallContext());
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(1, sessionManager.InvokeCount);
}
/// <summary>Verifies that Invoke throws InvalidArgument and does not invoke the session manager when payload is mismatched.</summary>
[Fact]
public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager()
{
FakeSessionManager sessionManager = new();
MxAccessGatewayService service = CreateService(sessionManager);
MxCommandRequest request = new()
{
SessionId = "session-1",
Command = new MxCommand
{
Kind = MxCommandKind.AddItem,
Ping = new PingCommand { Message = "wrong-payload" },
},
};
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.Invoke(request, new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(0, sessionManager.InvokeCount);
}
/// <summary>Verifies that Invoke returns HResult status and method payload from worker reply.</summary>
[Fact]
public async Task Invoke_WithWorkerReply_ReturnsHresultStatusAndMethodPayload()
{
const int hresult = unchecked((int)0x80004005);
FakeSessionManager sessionManager = new()
{
InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "worker-correlation",
Kind = MxCommandKind.AddItem,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
Hresult = hresult,
AddItem = new AddItemReply { ItemHandle = 42 },
DiagnosticMessage = "mxaccess diagnostic",
},
},
};
sessionManager.InvokeReply.Reply.Statuses.Add(new MxStatusProxy
{
Success = 0,
Category = MxStatusCategory.SoftwareError,
Detail = 1001,
DiagnosticText = "status detail",
});
MxAccessGatewayService service = CreateService(sessionManager);
MxCommandRequest request = new()
{
SessionId = "session-1",
ClientCorrelationId = "client-correlation",
Command = new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = 12,
ItemDefinition = "Galaxy.Tag.Value",
},
},
};
MxCommandReply reply = await service.Invoke(request, new TestServerCallContext());
Assert.Equal(MxCommandKind.AddItem, sessionManager.LastWorkerCommand?.Command.Kind);
Assert.Equal("Galaxy.Tag.Value", sessionManager.LastWorkerCommand?.Command.AddItem.ItemDefinition);
Assert.NotNull(sessionManager.LastWorkerCommand?.EnqueueTimestamp);
Assert.Equal(hresult, reply.Hresult);
Assert.Equal(42, reply.AddItem.ItemHandle);
Assert.Equal("status detail", Assert.Single(reply.Statuses).DiagnosticText);
Assert.Equal("mxaccess diagnostic", reply.DiagnosticMessage);
}
/// <summary>Verifies that StreamEvents writes only events after the specified worker sequence.</summary>
[Fact]
public async Task StreamEvents_WithAfterSequence_WritesOnlyLaterEvents()
{
FakeSessionManager sessionManager = new();
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 1));
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
MxAccessGatewayService service = CreateService(sessionManager);
RecordingServerStreamWriter<MxEvent> writer = new();
await service.StreamEvents(
new StreamEventsRequest
{
SessionId = "session-1",
AfterWorkerSequence = 1,
},
writer,
new TestServerCallContext());
MxEvent writtenEvent = Assert.Single(writer.Messages);
Assert.Equal((ulong)2, writtenEvent.WorkerSequence);
Assert.Equal("session-1", sessionManager.LastReadEventsSessionId);
}
/// <summary>Verifies that StreamEvents records send duration metrics when an event is written.</summary>
[Fact]
public async Task StreamEvents_WhenEventIsWritten_RecordsSendDuration()
{
using GatewayMetrics metrics = new();
using MeterListener listener = new();
List<string> families = [];
listener.InstrumentPublished = (instrument, meterListener) =>
{
if (instrument.Meter.Name == GatewayMetrics.MeterName
&& instrument.Name == "mxgateway.events.stream_send.duration")
{
meterListener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<double>(
(instrument, measurement, tags, _) =>
{
if (instrument.Name != "mxgateway.events.stream_send.duration")
{
return;
}
foreach (KeyValuePair<string, object?> tag in tags)
{
if (tag.Key == "family" && tag.Value is string family)
{
families.Add(family);
}
}
});
listener.Start();
FakeSessionManager sessionManager = new();
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
MxAccessGatewayService service = CreateService(sessionManager, metrics: metrics);
RecordingServerStreamWriter<MxEvent> writer = new();
await service.StreamEvents(
new StreamEventsRequest { SessionId = "session-1" },
writer,
new TestServerCallContext());
Assert.Equal([MxEventFamily.OnDataChange.ToString()], families);
}
/// <summary>Verifies that CloseSession throws InvalidArgument when session ID is blank.</summary>
[Fact]
public async Task CloseSession_WithBlankSessionId_ThrowsInvalidArgument()
{
MxAccessGatewayService service = CreateService(new FakeSessionManager());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.CloseSession(
new CloseSessionRequest(),
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
// ===== AcknowledgeAlarm + StreamAlarms handler contract =====
//
// AcknowledgeAlarm validates alarm_full_reference then delegates to the
// session-less IGatewayAlarmService; StreamAlarms forwards the central
// alarm feed. CreateService injects FakeGatewayAlarmService.
/// <summary>Verifies AcknowledgeAlarm rejects an empty alarm_full_reference.</summary>
[Fact]
public async Task AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument()
{
MxAccessGatewayService service = CreateService(new FakeSessionManager());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.AcknowledgeAlarm(
new AcknowledgeAlarmRequest { OperatorUser = "alice" },
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
/// <summary>Verifies AcknowledgeAlarm delegates a valid request to the alarm service.</summary>
[Fact]
public async Task AcknowledgeAlarm_WithValidRequest_DelegatesToAlarmService()
{
MxAccessGatewayService service = CreateService(new FakeSessionManager());
AcknowledgeAlarmReply reply = await service.AcknowledgeAlarm(
new AcknowledgeAlarmRequest
{
ClientCorrelationId = "corr-1",
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
Comment = "investigating",
OperatorUser = "alice",
},
new TestServerCallContext());
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal("corr-1", reply.CorrelationId);
}
/// <summary>Verifies StreamAlarms forwards the central alarm feed, ending with snapshot_complete.</summary>
[Fact]
public async Task StreamAlarms_ForwardsTheCentralAlarmFeed()
{
MxAccessGatewayService service = CreateService(new FakeSessionManager());
RecordingServerStreamWriter<AlarmFeedMessage> sink = new();
await service.StreamAlarms(
new StreamAlarmsRequest(),
sink,
new TestServerCallContext());
Assert.Contains(
sink.Messages,
message => message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotComplete);
}
/// <summary>Verifies OpenSession advertises the alarm RPC capability strings.</summary>
[Fact]
public async Task OpenSession_AdvertisesAlarmRpcCapabilities()
{
FakeSessionManager sessionManager = new();
GatewayRequestIdentityAccessor identityAccessor = new();
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
using IDisposable identityScope = identityAccessor.Push(CreateIdentity());
OpenSessionReply reply = await service.OpenSession(
new OpenSessionRequest(),
new TestServerCallContext());
Assert.Contains("unary-acknowledge-alarm", reply.Capabilities);
Assert.Contains("server-stream-active-alarms", reply.Capabilities);
}
private static MxAccessGatewayService CreateService(
FakeSessionManager sessionManager,
IGatewayRequestIdentityAccessor? identityAccessor = null,
GatewayMetrics? metrics = null,
IConstraintEnforcer? constraintEnforcer = null)
{
return new MxAccessGatewayService(
sessionManager,
identityAccessor ?? new GatewayRequestIdentityAccessor(),
constraintEnforcer ?? new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new FakeEventStreamService(sessionManager),
metrics ?? new GatewayMetrics(),
NullLogger<MxAccessGatewayService>.Instance,
new FakeGatewayAlarmService());
}
private static ApiKeyIdentity CreateIdentity()
{
return new ApiKeyIdentity(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
DisplayName: "Operator Key",
Scopes: new HashSet<string>(StringComparer.Ordinal));
}
private static GatewaySession CreateSession(
string sessionId,
int processId)
{
GatewaySession session = new(
sessionId,
GatewayContractInfo.DefaultBackendName,
"pipe",
"nonce",
"Operator Key",
"operator-session",
"client-correlation",
TimeSpan.FromSeconds(7),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(10),
DateTimeOffset.UtcNow);
session.AttachWorkerClient(new FakeWorkerClient(processId));
session.MarkReady();
return session;
}
private static MxCommandRequest CreatePingRequest(string sessionId)
{
return new MxCommandRequest
{
SessionId = sessionId,
Command = new MxCommand
{
Kind = MxCommandKind.Ping,
Ping = new PingCommand { Message = "ping" },
},
};
}
private static WorkerEvent CreateWorkerEvent(
string sessionId,
ulong workerSequence)
{
return new WorkerEvent
{
Event = new MxEvent
{
Family = MxEventFamily.OnDataChange,
SessionId = sessionId,
WorkerSequence = workerSequence,
OnDataChange = new OnDataChangeEvent(),
},
};
}
private sealed class FakeSessionManager : ISessionManager
{
private readonly Dictionary<string, GatewaySession> seededSessions = new(StringComparer.Ordinal);
/// <summary>The session to return from OpenSessionAsync.</summary>
public GatewaySession? OpenSessionResult { get; init; }
/// <summary>
/// When true, <see cref="TryGetSession"/> only resolves sessions that have been
/// explicitly seeded via <see cref="SeedSession"/> (or <see cref="OpenSessionResult"/>),
/// and returns false for any other id. This exercises the gateway service's own
/// missing-session handling instead of masking it with a synthesized session.
/// </summary>
public bool ResolveOnlySeededSessions { get; init; }
/// <summary>Registers a session so <see cref="TryGetSession"/> resolves its id.</summary>
/// <param name="session">Session to register by its <see cref="GatewaySession.SessionId"/>.</param>
public void SeedSession(GatewaySession session)
{
seededSessions[session.SessionId] = session;
}
/// <summary>The last OpenSessionAsync request captured.</summary>
public SessionOpenRequest? LastOpenRequest { get; private set; }
/// <summary>The last client identity passed to OpenSessionAsync.</summary>
public string? LastClientIdentity { get; private set; }
/// <summary>The last session ID passed to ReadEventsAsync.</summary>
public string? LastReadEventsSessionId { get; private set; }
/// <summary>The last worker command passed to InvokeAsync.</summary>
public WorkerCommand? LastWorkerCommand { get; private set; }
/// <summary>The reply to return from InvokeAsync.</summary>
public WorkerCommandReply InvokeReply { get; init; } = new()
{
Reply = new MxCommandReply
{
SessionId = "session-1",
Kind = MxCommandKind.Ping,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
},
};
/// <summary>The exception to throw from InvokeAsync.</summary>
public Exception? InvokeException { get; init; }
/// <summary>The number of times InvokeAsync was called.</summary>
public int InvokeCount { get; private set; }
/// <summary>The events to return from ReadEventsAsync.</summary>
public List<WorkerEvent> Events { get; } = [];
/// <summary>Records the session ID passed to ReadEventsAsync.</summary>
/// <param name="sessionId">Identifier of the session.</param>
public void RecordReadEventsSessionId(string sessionId)
{
LastReadEventsSessionId = sessionId;
}
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
CancellationToken cancellationToken)
{
LastOpenRequest = request;
LastClientIdentity = clientIdentity;
return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234));
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
out GatewaySession session)
{
if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded))
{
session = seeded;
return true;
}
if (ResolveOnlySeededSessions)
{
session = null!;
return false;
}
session = OpenSessionResult ?? CreateSession(sessionId, processId: 1234);
return true;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken)
{
InvokeCount++;
LastWorkerCommand = command;
if (InvokeException is not null)
{
throw InvokeException;
}
return Task.FromResult(InvokeReply);
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
LastReadEventsSessionId = sessionId;
foreach (WorkerEvent workerEvent in Events)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return workerEvent;
}
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
{
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
{
return Task.FromResult(0);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
{
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
sessionManager.RecordReadEventsSessionId(request.SessionId);
foreach (WorkerEvent workerEvent in sessionManager.Events)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
if (workerEvent.Event.WorkerSequence <= request.AfterWorkerSequence)
{
continue;
}
yield return workerEvent.Event;
}
}
}
private sealed class FakeWorkerClient(int processId) : IWorkerClient
{
/// <inheritdoc />
public string SessionId { get; } = "session-1";
/// <inheritdoc />
public int? ProcessId { get; } = processId;
/// <inheritdoc />
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken)
{
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
/// <inheritdoc />
public Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public void Kill(string reason)
{
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,79 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Grpc;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
public sealed class MxAccessGrpcMapperTests
{
/// <summary>Verifies that command mapping clones payloads to isolate them across process boundaries.</summary>
[Fact]
public void MapCommand_ClonesMethodSpecificPayloadForWorkerBoundary()
{
MxAccessGrpcMapper mapper = new();
MxCommandRequest request = new()
{
SessionId = "session-1",
Command = new MxCommand
{
Kind = MxCommandKind.Write,
Write = new WriteCommand
{
ServerHandle = 10,
ItemHandle = 20,
UserId = 30,
Value = new MxValue
{
DataType = MxDataType.String,
StringValue = "value",
},
},
},
};
WorkerCommand workerCommand = mapper.MapCommand(request);
request.Command.Write.Value.StringValue = "changed";
Assert.Equal(MxCommandKind.Write, workerCommand.Command.Kind);
Assert.Equal("value", workerCommand.Command.Write.Value.StringValue);
Assert.NotNull(workerCommand.EnqueueTimestamp);
}
/// <summary>Verifies that command reply mapping preserves HRESULT and status information.</summary>
[Fact]
public void MapCommandReply_PreservesHresultStatusesAndPayload()
{
const int hresult = unchecked((int)0x80070005);
WorkerCommandReply workerReply = new()
{
Reply = new MxCommandReply
{
SessionId = "session-1",
Kind = MxCommandKind.Register,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
Hresult = hresult,
Register = new RegisterReply { ServerHandle = 50 },
},
};
workerReply.Reply.Statuses.Add(new MxStatusProxy
{
Success = 0,
Category = MxStatusCategory.SecurityError,
DiagnosticText = "denied",
});
MxCommandReply publicReply = new MxAccessGrpcMapper().MapCommandReply(workerReply);
Assert.Equal(hresult, publicReply.Hresult);
Assert.Equal(50, publicReply.Register.ServerHandle);
Assert.Equal("denied", Assert.Single(publicReply.Statuses).DiagnosticText);
}
/// <summary>Verifies that a missing worker reply returns a protocol violation status.</summary>
[Fact]
public void MapCommandReply_WhenWorkerReplyMissing_ReturnsProtocolViolationReply()
{
MxCommandReply publicReply = new MxAccessGrpcMapper().MapCommandReply(new WorkerCommandReply());
Assert.Equal(ProtocolStatusCode.ProtocolViolation, publicReply.ProtocolStatus.Code);
}
}