feat(clients): CLI-04 typed single-item command parity (+CLI-30 unregister) — 4/5 clients
Every parity-critical single-item MXAccess command now has a typed session helper instead of only a raw-Invoke escape hatch. Added per client: - Phase 1: AdviseSupervisory, WriteSecured, WriteSecured2, AuthenticateUser, ArchestrAUserToId - Phase 2: AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate - CLI-30: Unregister (Rust + .NET; Go/Python already had it) Each wraps the existing raw-command machinery (no new wire surface) and runs the same MXAccess-level reply validation (hresult < 0 + MxStatusProxy). MXAccess parity preserved: WriteSecured before AuthenticateUser+AdviseSupervisory surfaces the native failure unchanged (not pre-validated/reordered). Credentials (AuthenticateUser password, WriteSecured payloads) route through each client's secret-redaction seam and never reach logs/exceptions/ToString/Debug/Display; each suite asserts a distinctive credential is absent from surfaced errors. New CLI subcommands source credentials via flag/env, never echoed. - .NET: 21 helpers (validated + Raw), CLI subcommands, multi-secret CLI redactor. Build clean (0 warn), 102 passed. - Go: 9 helpers + *Raw variants, redactSecrets seam, promoted CLI advise-supervisory to typed. gofmt/vet/build/test clean. - Rust: 10 helpers incl. unregister; verified ensure_mxaccess_success runs on secured paths; error.rs credential scrub. fmt/check/test/clippy clean. - Python: 9 async helpers, redact_secret seam + _invoke_redacted, CLI commands. 145 passed. - Shared doc: ClientLibrariesDesign "Typed Command Parity" section. Java client typed parity is batched to windev (no local JRE); CLI-04 + CLI-30 stay open until it lands. Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
@@ -415,6 +415,297 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal(7, el.Value.Int32Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unregister builds an unregister command with the server handle.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that suspend builds the command and returns the reply status.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that activate builds the command and returns the reply status.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MXAccess parity: WriteSecured issued before a prior AuthenticateUser fails
|
||||
/// natively. The client must surface that scripted failure (as an
|
||||
/// <see cref="MxAccessException"/>) rather than pre-validating it away.
|
||||
/// </summary>
|
||||
[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<MxAccessException>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<MxAccessException>(
|
||||
async () => await session.AuthenticateUserAsync(12, "operator", password));
|
||||
|
||||
Assert.DoesNotContain(password, exception.Message);
|
||||
Assert.DoesNotContain(password, exception.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary>
|
||||
[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);
|
||||
|
||||
Reference in New Issue
Block a user