using ZB.MOM.WW.MxGateway.Contracts.Proto; using Grpc.Core; namespace ZB.MOM.WW.MxGateway.Client.Tests; /// Tests for MxGatewaySession and client command behavior. public sealed class MxGatewayClientSessionTests { /// Verifies that open session attaches API key metadata and cancellation token. [Fact] public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation() { using CancellationTokenSource cancellation = new(); FakeGatewayTransport transport = CreateTransport(); await using MxGatewayClient client = CreateClient(transport); await client.OpenSessionRawAsync(new OpenSessionRequest(), cancellation.Token); var call = Assert.Single(transport.OpenSessionCalls); Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization")); Assert.Equal(cancellation.Token, call.CallOptions.CancellationToken); } /// Verifies that open session returns a session with the raw open reply. [Fact] public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply() { FakeGatewayTransport transport = CreateTransport(); transport.OpenSessionReply.WorkerProcessId = 1234; await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); Assert.Equal("session-fixture", session.SessionId); Assert.Same(transport.OpenSessionReply, session.OpenSessionReply); Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId); } /// Verifies that register builds a register command and returns server handle. [Fact] public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.Register, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, Register = new RegisterReply { ServerHandle = 12 }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); int serverHandle = await session.RegisterAsync("fixture-client"); Assert.Equal(12, serverHandle); var call = Assert.Single(transport.InvokeCalls); Assert.Equal("session-fixture", call.Request.SessionId); Assert.False(string.IsNullOrWhiteSpace(call.Request.ClientCorrelationId)); Assert.Equal(MxCommandKind.Register, call.Request.Command.Kind); Assert.Equal("fixture-client", call.Request.Command.Register.ClientName); } /// Verifies that add item 2 builds a command with the specified context. [Fact] public async Task AddItem2Async_BuildsAddItem2CommandWithContext() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.AddItem2, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, AddItem2 = new AddItem2Reply { ItemHandle = 34 }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); int itemHandle = await session.AddItem2Async(12, "Area001.Pump001.Speed", "runtime"); Assert.Equal(34, itemHandle); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.AddItem2, request.Command.Kind); Assert.Equal(12, request.Command.AddItem2.ServerHandle); Assert.Equal("Area001.Pump001.Speed", request.Command.AddItem2.ItemDefinition); Assert.Equal("runtime", request.Command.AddItem2.ItemContext); } /// Verifies that write raw builds a write command with the raw value. [Fact] public async Task WriteRawAsync_BuildsWriteCommandWithRawValue() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.Write, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); MxValue value = new() { DataType = MxDataType.Integer, VariantType = "VT_I4", Int32Value = 123, }; MxCommandReply reply = await session.WriteRawAsync(12, 34, value, 56); Assert.Equal(MxCommandKind.Write, reply.Kind); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.Write, request.Command.Kind); Assert.Equal(12, request.Command.Write.ServerHandle); Assert.Equal(34, request.Command.Write.ItemHandle); Assert.Same(value, request.Command.Write.Value); Assert.Equal(56, request.Command.Write.UserId); } /// Verifies that write 2 raw builds a write 2 command with value and timestamp. [Fact] public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.Write2, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); MxValue value = 123.ToMxValue(); MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue(); MxCommandReply reply = await session.Write2RawAsync(12, 34, value, timestampValue, 56); Assert.Equal(MxCommandKind.Write2, reply.Kind); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.Write2, request.Command.Kind); Assert.Equal(12, request.Command.Write2.ServerHandle); Assert.Equal(34, request.Command.Write2.ItemHandle); Assert.Same(value, request.Command.Write2.Value); Assert.Same(timestampValue, request.Command.Write2.TimestampValue); Assert.Equal(56, request.Command.Write2.UserId); } /// Verifies that subscribe bulk builds one command and returns per-item results. [Fact] public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.SubscribeBulk, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, SubscribeBulk = new BulkSubscribeReply { Results = { new SubscribeResult { ServerHandle = 12, TagAddress = "Area001.Pump001.Speed", ItemHandle = 34, WasSuccessful = true, }, }, }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); IReadOnlyList results = await session.SubscribeBulkAsync( 12, ["Area001.Pump001.Speed"]); SubscribeResult result = Assert.Single(results); Assert.Equal(34, result.ItemHandle); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.SubscribeBulk, request.Command.Kind); Assert.Equal(12, request.Command.SubscribeBulk.ServerHandle); Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses); } /// Verifies that stream events yields events in the order received from the gateway. [Fact] public async Task StreamEventsAsync_YieldsEventsInGatewayOrder() { FakeGatewayTransport transport = CreateTransport(); transport.AddEvent(new MxEvent { SessionId = "session-fixture", Family = MxEventFamily.OnDataChange, WorkerSequence = 1, }); transport.AddEvent(new MxEvent { SessionId = "session-fixture", Family = MxEventFamily.OnWriteComplete, WorkerSequence = 2, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); List sequences = []; await foreach (MxEvent gatewayEvent in session.StreamEventsAsync(afterWorkerSequence: 0)) { sequences.Add(gatewayEvent.WorkerSequence); } Assert.Equal([1UL, 2UL], sequences); StreamEventsRequest request = Assert.Single(transport.StreamEventsCalls).Request; Assert.Equal("session-fixture", request.SessionId); } /// /// Verifies that a reconnect-replay gap sentinel is surfaced as a typed /// with the gap populated, while normal /// events pass through unchanged with IsReplayGap false. /// [Fact] public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel() { FakeGatewayTransport transport = CreateTransport(); transport.AddEvent(new MxEvent { SessionId = "session-fixture", ReplayGap = new ReplayGap { RequestedAfterSequence = 5, OldestAvailableSequence = 42, }, }); transport.AddEvent(new MxEvent { SessionId = "session-fixture", Family = MxEventFamily.OnDataChange, WorkerSequence = 42, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); List items = []; await foreach (MxEventStreamItem item in session.StreamEventItemsAsync(afterWorkerSequence: 5)) { items.Add(item); } Assert.Equal(2, items.Count); MxEventStreamItem gap = items[0]; Assert.True(gap.IsReplayGap); Assert.NotNull(gap.ReplayGap); Assert.Equal(5UL, gap.ReplayGap!.RequestedAfterSequence); Assert.Equal(42UL, gap.ReplayGap.OldestAvailableSequence); Assert.Same(gap.ReplayGap, gap.Event.ReplayGap); MxEventStreamItem normal = items[1]; Assert.False(normal.IsReplayGap); Assert.Null(normal.ReplayGap); Assert.Equal(42UL, normal.Event.WorkerSequence); Assert.Equal(MxEventFamily.OnDataChange, normal.Event.Family); } /// Verifies that close is explicit and idempotent. [Fact] public async Task CloseAsync_IsExplicitAndIdempotent() { FakeGatewayTransport transport = CreateTransport(); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); CloseSessionReply first = await session.CloseAsync(); CloseSessionReply second = await session.CloseAsync(); Assert.Same(first, second); var call = Assert.Single(transport.CloseSessionCalls); Assert.Equal("session-fixture", call.Request.SessionId); } /// Verifies that invoke retries safe diagnostic commands on transient RPC failure. [Fact] public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure() { FakeGatewayTransport transport = CreateTransport(); transport.InvokeExceptions.Enqueue(CreateTransientRpcException()); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.Ping, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); await session.InvokeAsync(new MxCommandRequest { SessionId = session.SessionId, Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() }, }); Assert.Equal(2, transport.InvokeCalls.Count); } /// Verifies that open session does not retry on transient RPC failure. [Fact] public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure() { FakeGatewayTransport transport = CreateTransport(); transport.OpenSessionExceptions.Enqueue(CreateTransientRpcException()); await using MxGatewayClient client = CreateClient(transport); await Assert.ThrowsAsync(async () => await client.OpenSessionAsync()); Assert.Single(transport.OpenSessionCalls); } /// Verifies that invoke does not retry write commands on transient RPC failure. [Fact] public async Task InvokeAsync_DoesNotRetryWriteCommand() { FakeGatewayTransport transport = CreateTransport(); transport.InvokeExceptions.Enqueue(CreateTransientRpcException()); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); await Assert.ThrowsAsync(async () => await session.WriteRawAsync(1, 2, 3.ToMxValue(), userId: 0)); Assert.Single(transport.InvokeCalls); } /// Verifies that invoke helpers pass cancellation token to the transport. [Fact] public async Task InvokeHelpers_PassCancellationTokenToTransport() { using CancellationTokenSource cancellation = new(); FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.Advise, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); await session.AdviseAsync(12, 34, cancellation.Token); Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken); } /// Verifies that BuildSparseArray produces a SparseArrayValue MxValue with the correct total length and elements. [Fact] public void BuildSparseArray_ProducesSparseArrayValueWithCorrectTotalLengthAndElements() { MxValue element0 = 42.ToMxValue(); MxValue element3 = 99.ToMxValue(); Dictionary elements = new() { [0u] = element0, [3u] = element3, }; MxValue result = MxGatewaySession.BuildSparseArray(MxDataType.Integer, totalLength: 10, elements); Assert.Equal(MxValue.KindOneofCase.SparseArrayValue, result.KindCase); Assert.Equal(10u, result.SparseArrayValue.TotalLength); Assert.Equal(MxDataType.Integer, result.SparseArrayValue.ElementDataType); Assert.Equal(2, result.SparseArrayValue.Elements.Count); MxSparseElement el0 = Assert.Single(result.SparseArrayValue.Elements, e => e.Index == 0u); Assert.Same(element0, el0.Value); MxSparseElement el3 = Assert.Single(result.SparseArrayValue.Elements, e => e.Index == 3u); Assert.Same(element3, el3.Value); } /// Verifies that WriteArrayElementsAsync builds a write command whose value is a SparseArrayValue. [Fact] public async Task WriteArrayElementsAsync_BuildsWriteCommandWithSparseArrayValue() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.Write, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); Dictionary elements = new() { [1u] = 7.ToMxValue() }; await session.WriteArrayElementsAsync( serverHandle: 12, itemHandle: 34, elementDataType: MxDataType.Integer, totalLength: 5, elements: elements, userId: 56); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.Write, request.Command.Kind); Assert.Equal(12, request.Command.Write.ServerHandle); Assert.Equal(34, request.Command.Write.ItemHandle); Assert.Equal(56, request.Command.Write.UserId); MxValue written = request.Command.Write.Value; Assert.Equal(MxValue.KindOneofCase.SparseArrayValue, written.KindCase); Assert.Equal(5u, written.SparseArrayValue.TotalLength); Assert.Equal(MxDataType.Integer, written.SparseArrayValue.ElementDataType); MxSparseElement el = Assert.Single(written.SparseArrayValue.Elements); Assert.Equal(1u, el.Index); Assert.Equal(7, el.Value.Int32Value); } /// Verifies that unregister builds an unregister command with the server handle. [Fact] public async Task UnregisterAsync_BuildsUnregisterCommand() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.Unregister, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); await session.UnregisterAsync(12); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.Unregister, request.Command.Kind); Assert.Equal(12, request.Command.Unregister.ServerHandle); } /// Verifies that advise-supervisory builds the supervisory advise command. [Fact] public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.AdviseSupervisory, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); await session.AdviseSupervisoryAsync(12, 34); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.AdviseSupervisory, request.Command.Kind); Assert.Equal(12, request.Command.AdviseSupervisory.ServerHandle); Assert.Equal(34, request.Command.AdviseSupervisory.ItemHandle); } /// Verifies that add-buffered-item returns the item handle from the typed reply. [Fact] public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.AddBufferedItem, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, AddBufferedItem = new AddBufferedItemReply { ItemHandle = 77 }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); int itemHandle = await session.AddBufferedItemAsync(12, "Area001.Pump001.Speed", "runtime"); Assert.Equal(77, itemHandle); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.AddBufferedItem, request.Command.Kind); Assert.Equal(12, request.Command.AddBufferedItem.ServerHandle); Assert.Equal("Area001.Pump001.Speed", request.Command.AddBufferedItem.ItemDefinition); Assert.Equal("runtime", request.Command.AddBufferedItem.ItemContext); } /// Verifies that set-buffered-update-interval builds the command with the interval. [Fact] public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.SetBufferedUpdateInterval, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); await session.SetBufferedUpdateIntervalAsync(12, 500); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.SetBufferedUpdateInterval, request.Command.Kind); Assert.Equal(12, request.Command.SetBufferedUpdateInterval.ServerHandle); Assert.Equal(500, request.Command.SetBufferedUpdateInterval.UpdateIntervalMilliseconds); } /// Verifies that suspend builds the command and returns the reply status. [Fact] public async Task SuspendAsync_BuildsCommandAndReturnsStatus() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.Suspend, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, Suspend = new SuspendReply { Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok } }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); MxStatusProxy? status = await session.SuspendAsync(12, 34); Assert.NotNull(status); Assert.Equal(1, status!.Success); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.Suspend, request.Command.Kind); Assert.Equal(12, request.Command.Suspend.ServerHandle); Assert.Equal(34, request.Command.Suspend.ItemHandle); } /// Verifies that activate builds the command and returns the reply status. [Fact] public async Task ActivateAsync_BuildsCommandAndReturnsStatus() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.Activate, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, Activate = new ActivateReply { Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok } }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); MxStatusProxy? status = await session.ActivateAsync(12, 34); Assert.NotNull(status); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.Activate, request.Command.Kind); Assert.Equal(34, request.Command.Activate.ItemHandle); } /// Verifies that write-secured builds a WriteSecured command with value and user ids. [Fact] public async Task WriteSecuredAsync_BuildsWriteSecuredCommand() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.WriteSecured, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); MxValue value = 123.ToMxValue(); await session.WriteSecuredAsync(12, 34, value, currentUserId: 5, verifierUserId: 6); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind); Assert.Equal(12, request.Command.WriteSecured.ServerHandle); Assert.Equal(34, request.Command.WriteSecured.ItemHandle); Assert.Same(value, request.Command.WriteSecured.Value); Assert.Equal(5, request.Command.WriteSecured.CurrentUserId); Assert.Equal(6, request.Command.WriteSecured.VerifierUserId); } /// /// MXAccess parity: WriteSecured issued before a prior AuthenticateUser fails /// natively. The client must surface that scripted failure (as an /// ) rather than pre-validating it away. /// [Fact] public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.WriteSecured, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure }, Hresult = unchecked((int)0x80040200), }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); MxAccessException exception = await Assert.ThrowsAsync( async () => await session.WriteSecuredAsync(12, 34, 123.ToMxValue(), currentUserId: 0, verifierUserId: 0)); // The native HRESULT is surfaced; the request payload is never in the message. Assert.Contains("WriteSecured", exception.Message); Assert.Single(transport.InvokeCalls); } /// Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp. [Fact] public async Task WriteSecured2Async_BuildsWriteSecured2Command() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.WriteSecured2, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); MxValue value = 123.ToMxValue(); MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue(); await session.WriteSecured2Async(12, 34, value, timestampValue, currentUserId: 5, verifierUserId: 6); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.WriteSecured2, request.Command.Kind); Assert.Same(value, request.Command.WriteSecured2.Value); Assert.Same(timestampValue, request.Command.WriteSecured2.TimestampValue); Assert.Equal(5, request.Command.WriteSecured2.CurrentUserId); Assert.Equal(6, request.Command.WriteSecured2.VerifierUserId); } /// Verifies that authenticate-user builds the command and returns the resolved user id. [Fact] public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.AuthenticateUser, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, AuthenticateUser = new AuthenticateUserReply { UserId = 4242 }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); int userId = await session.AuthenticateUserAsync(12, "operator", "s3cr3t-p@ss"); Assert.Equal(4242, userId); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind); Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser); Assert.Equal("s3cr3t-p@ss", request.Command.AuthenticateUser.VerifyUserPassword); } /// /// SECRET REDACTION: when AuthenticateUser fails, the surfaced exception message /// must never contain the credential — the error path is built only from /// reply-derived diagnostics, not the request payload. /// [Fact] public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage() { const string password = "super-secret-credential-123"; FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.AuthenticateUser, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure }, Hresult = unchecked((int)0x80040210), }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); MxAccessException exception = await Assert.ThrowsAsync( async () => await session.AuthenticateUserAsync(12, "operator", password)); Assert.DoesNotContain(password, exception.Message); Assert.DoesNotContain(password, exception.ToString()); } /// Verifies that archestra-user-to-id builds the command and returns the resolved user id. [Fact] public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.ArchestraUserToId, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, ArchestraUserToId = new ArchestrAUserToIdReply { UserId = 909 }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); int userId = await session.ArchestraUserToIdAsync(12, "BCC47053-9542-4D65-BDAA-BCDEA6A32A73"); Assert.Equal(909, userId); MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; Assert.Equal(MxCommandKind.ArchestraUserToId, request.Command.Kind); Assert.Equal("BCC47053-9542-4D65-BDAA-BCDEA6A32A73", request.Command.ArchestraUserToId.UserIdGuid); } private static MxGatewayClient CreateClient(FakeGatewayTransport transport) { return new MxGatewayClient(transport.Options, transport); } private static FakeGatewayTransport CreateTransport() { return new FakeGatewayTransport(new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = "test-api-key", }); } private static RpcException CreateTransientRpcException() { return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable")); } }