using Google.Protobuf; using ZB.MOM.WW.MxGateway.Contracts.Proto; namespace ZB.MOM.WW.MxGateway.Client.Tests; /// /// Tests for the credential-scrub (CLI-40) and malformed-reply (CLI-41) contracts on the /// credential and id-returning session helpers, driven from shared behavior fixtures. /// public sealed class MxGatewaySessionReplyContractTests { /// /// CLI-40: when MXAccess echoes the submitted credential back in its failure diagnostic, /// the surfaced exception message must scrub it to the library redaction marker. /// [Fact] public async Task AuthenticateUserAsync_RedactsEchoedCredentialInFailureMessage() { const string password = "sup3rSecretVerify9f3a2b"; FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(ReadReplyFixture("authenticate-user.echoed-credential.reply.json")); 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, StringComparison.Ordinal); Assert.Contains("", exception.Message, StringComparison.Ordinal); // ToString() is what logging frameworks emit; the secret-bearing original must not be // chained as an inner exception where it would re-surface the credential verbatim. Assert.DoesNotContain(password, exception.ToString(), StringComparison.Ordinal); } /// /// CLI-40: the redacted exception must not leak the echoed credential through any structured /// accessor either — (protocol message, diagnostic /// message, and each MXSTATUS_PROXY diagnostic text) and /// all carry the server-echoed credential verbatim before the fix. Both the OK+negative-HRESULT /// and the MXACCESS_FAILURE reply route to , so both must scrub. /// /// The echoed-credential reply fixture to drive. [Theory] [InlineData("authenticate-user.echoed-credential.reply.json")] [InlineData("authenticate-user.echoed-credential-mxaccess-failure.reply.json")] public async Task AuthenticateUserAsync_RedactsEchoedCredentialInStructuredAccessors(string fixture) { const string password = "sup3rSecretVerify9f3a2b"; FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(ReadReplyFixture(fixture)); 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, StringComparison.Ordinal); Assert.Contains("", exception.Message, StringComparison.Ordinal); Assert.DoesNotContain(password, exception.ToString(), StringComparison.Ordinal); Assert.DoesNotContain(password, exception.Reply.ProtocolStatus.Message, StringComparison.Ordinal); Assert.DoesNotContain(password, exception.Reply.DiagnosticMessage, StringComparison.Ordinal); foreach (MxStatusProxy status in exception.Reply.Statuses) { Assert.DoesNotContain(password, status.DiagnosticText, StringComparison.Ordinal); } foreach (MxStatusProxy status in exception.Statuses) { Assert.DoesNotContain(password, status.DiagnosticText, StringComparison.Ordinal); } } /// /// CLI-41: an OK reply that carries neither the typed AuthenticateUser payload nor an /// int32 return_value is a malformed reply, surfaced as a typed exception rather than an NRE. /// [Fact] public async Task AuthenticateUserAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(ReadReplyFixture("authenticate-user.missing-payload.reply.json")); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); await Assert.ThrowsAsync( async () => await session.AuthenticateUserAsync(12, "operator", "pw")); } /// /// CLI-41: an OK reply that omits the typed payload but carries an int32 return_value /// resolves to that return value. /// [Fact] public async Task AuthenticateUserAsync_ReturnValueOnly_ResolvesReturnValue() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(ReadReplyFixture("authenticate-user.return-value-only.reply.json")); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); int userId = await session.AuthenticateUserAsync(12, "operator", "pw"); Assert.Equal(7, userId); } /// /// CLI-41: the AddBufferedItem fallback shares the malformed-reply contract — an OK reply /// with neither a typed item handle nor an int32 return_value throws the typed exception. /// [Fact] public async Task AddBufferedItemAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply() { FakeGatewayTransport transport = CreateTransport(); transport.AddInvokeReply(new MxCommandReply { SessionId = "session-fixture", Kind = MxCommandKind.AddBufferedItem, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }); await using MxGatewayClient client = CreateClient(transport); MxGatewaySession session = await client.OpenSessionAsync(); await Assert.ThrowsAsync( async () => await session.AddBufferedItemAsync(12, "Area001.Pump001.Speed", "runtime")); } 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 MxCommandReply ReadReplyFixture(string fileName) { DirectoryInfo directory = new(AppContext.BaseDirectory); while (directory is not null) { string path = Path.Combine( directory.FullName, "clients", "proto", "fixtures", "behavior", "command-replies", fileName); if (File.Exists(path)) { return JsonParser.Default.Parse(File.ReadAllText(path)); } directory = directory.Parent!; } throw new FileNotFoundException(fileName); } }