fix(CLI-40,CLI-41,CLI-44): exact-secret scrub, uniform malformed-reply contract, Go terminal-error mislabel
CLI-40: port the exact-secret credential scrub to Rust/Java/.NET (Go/Python
already did it). AuthenticateUser/WriteSecured(2) helpers now redact the exact
caller-supplied secret from any surfaced error, as defense-in-depth on top of the
by-construction guarantee. Rust hand-writes a redacting Debug (derived Debug would
leak the reply); Java/.NET rebuild the same exception type with the redacted
message and do not carry the secret-bearing original forward (so ToString/stack
traces stay clean too).
CLI-41: uniform malformed-reply contract for AuthenticateUser/ArchestrAUserToId/
AddBufferedItem across all five clients — typed payload, else a present int32
return_value, else a typed malformed-reply error. Fixes Go/Java silent-0, .NET
NRE, and Rust's own internal inconsistency.
CLI-44: the Go event goroutine's Recv-error path now uses a non-blocking
sendTerminalEventResult on the reserved slot, so a genuine terminal stream error
is reported as itself instead of being mislabeled ErrSlowConsumer under overflow.
Riders from the CLI-37/38 review: (a) .NET ToDiagnosticSummary and Python
_mxaccess_message surface the raw success member (diagnostics-only parity with
Rust); (b) the status-conversion fixture carries an independent wantSuccess
boolean and the Go/.NET fixture tests assert against it instead of recomputing
the formula under test.
Shared fixtures (authenticate-user.{echoed-credential,missing-payload,
return-value-only}.reply.json) + manifest + ClientBehaviorFixtures.md +
ClientLibrariesDesign.md updated in the same change. Tracking: CLI-40/41/44 -> Done.
This commit is contained in:
+127
@@ -0,0 +1,127 @@
|
||||
using Google.Protobuf;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class MxGatewaySessionReplyContractTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<MxAccessException>(
|
||||
async () => await session.AuthenticateUserAsync(12, "operator", password));
|
||||
|
||||
Assert.DoesNotContain(password, exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("<redacted>", 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<MxGatewayMalformedReplyException>(
|
||||
async () => await session.AuthenticateUserAsync(12, "operator", "pw"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: an OK reply that omits the typed payload but carries an int32 return_value
|
||||
/// resolves to that return value.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<MxGatewayMalformedReplyException>(
|
||||
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<MxCommandReply>(File.ReadAllText(path));
|
||||
}
|
||||
|
||||
directory = directory.Parent!;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(fileName);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ public sealed class MxStatusProxyExtensionsTests
|
||||
MxStatusProxy status = JsonParser.Default.Parse<MxStatusProxy>(
|
||||
testCase.GetProperty("status").GetRawText());
|
||||
|
||||
Assert.Equal(status.Category is MxStatusCategory.Ok, status.IsSuccess());
|
||||
bool wantSuccess = testCase.GetProperty("wantSuccess").GetBoolean();
|
||||
Assert.Equal(wantSuccess, status.IsSuccess());
|
||||
Assert.Equal(
|
||||
testCase.GetProperty("status").GetProperty("rawCategory").GetInt32(),
|
||||
status.RawCategory);
|
||||
|
||||
Reference in New Issue
Block a user