Merge branch 'fix/archreview-p2' into main (P2 tier: completeness & polish)

# Conflicts:
#	archreview/remediation/00-tracking.md
#	clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs
#	clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs
#	src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs
This commit is contained in:
Joseph Doherty
2026-07-12 22:12:41 -04:00
91 changed files with 7680 additions and 681 deletions
+42
View File
@@ -84,6 +84,48 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
available, and command helpers have `*RawAsync` variants when callers need the
complete `MxCommandReply`.
### Event Streaming And Replay Gaps
`StreamEventsAsync(afterWorkerSequence)` yields raw generated `MxEvent`
messages. Passing a non-zero `afterWorkerSequence` resumes a session's event
stream after a known worker sequence — this is the reconnect cursor. If that
cursor is *stale* — older than the oldest event the gateway still retains in the
session replay ring — the events in between were evicted and cannot be replayed.
The gateway signals this by emitting a single **replay-gap sentinel** at the head
of the resumed stream: an `MxEvent` with its `ReplayGap` field set, `Family`
unspecified, and no body. It means "you missed events — discard local state and
re-snapshot."
Rather than force callers to inspect the raw sentinel, the client exposes a
typed surface, `StreamEventItemsAsync`, which yields `MxEventStreamItem` values:
```csharp
await foreach (MxEventStreamItem item in session.StreamEventItemsAsync(
afterWorkerSequence: lastSeenSequence))
{
if (item.IsReplayGap)
{
// We missed events: throw away local state and re-snapshot.
ReplayGap gap = item.ReplayGap!;
// Resume without incurring another gap:
lastSeenSequence = gap.OldestAvailableSequence - 1;
await ReSnapshotAsync();
continue;
}
HandleEvent(item.Event); // normal MXAccess event, IsReplayGap == false
lastSeenSequence = item.Event.WorkerSequence;
}
```
The typed surface never synthesizes or drops events — it only makes the
gateway's own sentinel observable. Normal events pass through with
`IsReplayGap == false` and `ReplayGap == null`. The gap is only ever produced by
`StreamEvents`; the diagnostic drain path never emits it. If you already consume
the raw `StreamEventsAsync` (or the client-level stream), the
`AsStreamItemsAsync()` extension projects any `IAsyncEnumerable<MxEvent>` into
the same `MxEventStreamItem` surface.
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
the active alarms the gateway's central monitor currently holds),
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
@@ -1,19 +1,32 @@
namespace ZB.MOM.WW.MxGateway.Client.Cli;
/// <summary>Utility to redact API keys from error messages for safe output.</summary>
/// <summary>Utility to redact secrets (API keys, MXAccess credentials) from error messages for safe output.</summary>
internal static class MxGatewayCliSecretRedactor
{
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
/// <summary>
/// Replaces every occurrence of any supplied secret in the value with a
/// redacted placeholder. Null or empty secrets are ignored, so callers can
/// pass optional credentials without pre-filtering.
/// </summary>
/// <param name="value">The message text to redact.</param>
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
/// <returns>The value with any occurrences of <paramref name="apiKey"/> replaced by a redacted placeholder.</returns>
public static string Redact(string value, string? apiKey)
/// <param name="secrets">The secret values to remove (API key, verify-user password, secured payloads).</param>
/// <returns>The value with any occurrences of the supplied secrets replaced by a redacted placeholder.</returns>
public static string Redact(string value, params string?[] secrets)
{
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
if (string.IsNullOrEmpty(value) || secrets is null)
{
return value;
}
return value.Replace(apiKey, "[redacted]", StringComparison.Ordinal);
string redacted = value;
foreach (string? secret in secrets)
{
if (!string.IsNullOrEmpty(secret))
{
redacted = redacted.Replace(secret, "[redacted]", StringComparison.Ordinal);
}
}
return redacted;
}
}
@@ -114,6 +114,24 @@ public static class MxGatewayClientCli
.ConfigureAwait(false),
"advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"unregister" => await UnregisterAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"add-buffered-item" => await AddBufferedItemAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"set-buffered-update-interval" => await SetBufferedUpdateIntervalAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"suspend" => await SuspendAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"activate" => await ActivateAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"write-secured" => await WriteSecuredAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"write-secured2" => await WriteSecured2Async(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"authenticate-user" => await AuthenticateUserAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"archestra-user-to-id" => await ArchestraUserToIdAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"subscribe-bulk" => await SubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
@@ -157,8 +175,17 @@ public static class MxGatewayClientCli
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Redact the *effective* key — from --api-key or the --api-key-env
// environment variable — so an env-var-sourced key echoed in a
// transport error never reaches stderr unredacted; likewise the
// MXAccess credentials (AuthenticateUser password, WriteSecured
// payloads) that could otherwise be echoed back in a surfaced error.
string? apiKey = TryResolveApiKey(arguments);
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
string message = MxGatewayCliSecretRedactor.Redact(
exception.Message,
apiKey,
TryResolveVerifyUserPassword(arguments),
arguments.GetOptional("value"));
if (forceJsonErrors || arguments.HasFlag("json"))
{
@@ -318,6 +345,48 @@ public static class MxGatewayClientCli
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
}
/// <summary>
/// Resolves the effective MXAccess verify-user credential from
/// <c>--verify-user-password</c> or, failing that, the
/// <c>--verify-user-password-env</c>-named environment variable (default
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>). The credential is never echoed;
/// this resolver exists so the error-redaction catch block can strip it
/// from any surfaced error (CLI-04), mirroring <see cref="TryResolveApiKey"/>.
/// </summary>
private static string? TryResolveVerifyUserPassword(CliArguments arguments)
{
string? password = arguments.GetOptional("verify-user-password");
if (!string.IsNullOrEmpty(password))
{
return password;
}
string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env")
?? "MXGATEWAY_VERIFY_USER_PASSWORD";
return Environment.GetEnvironmentVariable(passwordEnvironmentName);
}
/// <summary>
/// Resolves the verify-user credential for <c>authenticate-user</c>, throwing
/// a redaction-safe error when neither the flag nor the env var is set. The
/// thrown message names only the option/env var, never the value.
/// </summary>
private static string ResolveVerifyUserPassword(CliArguments arguments)
{
string? password = TryResolveVerifyUserPassword(arguments);
if (!string.IsNullOrEmpty(password))
{
return password;
}
string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env")
?? "MXGATEWAY_VERIFY_USER_PASSWORD";
throw new ArgumentException(
$"Verify-user password is required. Pass --verify-user-password or set {passwordEnvironmentName}.");
}
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
{
var cancellation = new CancellationTokenSource();
@@ -474,6 +543,215 @@ public static class MxGatewayClientCli
cancellationToken);
}
private static Task<int> UnregisterAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.Unregister,
Unregister = new UnregisterCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
},
},
cancellationToken);
}
private static Task<int> AddBufferedItemAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.AddBufferedItem,
AddBufferedItem = new AddBufferedItemCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
ItemDefinition = arguments.GetRequired("item"),
ItemContext = arguments.GetOptional("item-context") ?? string.Empty,
},
},
cancellationToken);
}
private static Task<int> SetBufferedUpdateIntervalAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.SetBufferedUpdateInterval,
SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
UpdateIntervalMilliseconds = arguments.GetInt32("interval-ms"),
},
},
cancellationToken);
}
private static Task<int> SuspendAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.Suspend,
Suspend = new SuspendCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
ItemHandle = arguments.GetInt32("item-handle"),
},
},
cancellationToken);
}
private static Task<int> ActivateAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.Activate,
Activate = new ActivateCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
ItemHandle = arguments.GetInt32("item-handle"),
},
},
cancellationToken);
}
private static Task<int> WriteSecuredAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.WriteSecured,
WriteSecured = new WriteSecuredCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
ItemHandle = arguments.GetInt32("item-handle"),
CurrentUserId = arguments.GetInt32("current-user-id"),
VerifierUserId = arguments.GetInt32("verifier-user-id", 0),
Value = ParseValue(arguments),
},
},
cancellationToken);
}
private static Task<int> WriteSecured2Async(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.WriteSecured2,
WriteSecured2 = new WriteSecured2Command
{
ServerHandle = arguments.GetInt32("server-handle"),
ItemHandle = arguments.GetInt32("item-handle"),
CurrentUserId = arguments.GetInt32("current-user-id"),
VerifierUserId = arguments.GetInt32("verifier-user-id", 0),
Value = ParseValue(arguments),
TimestampValue = ParseTimestampValue(arguments),
},
},
cancellationToken);
}
private static Task<int> AuthenticateUserAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
// The credential is resolved from --verify-user-password or its env var and
// is never echoed. On any surfaced error the RunCoreAsync catch block routes
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.AuthenticateUser,
AuthenticateUser = new AuthenticateUserCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
VerifyUser = arguments.GetRequired("verify-user"),
VerifyUserPassword = ResolveVerifyUserPassword(arguments),
},
},
cancellationToken);
}
private static Task<int> ArchestraUserToIdAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.ArchestraUserToId,
ArchestraUserToId = new ArchestrAUserToIdCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
UserIdGuid = arguments.GetRequired("user-guid"),
},
},
cancellationToken);
}
private static Task<int> SubscribeBulkAsync(
CliArguments arguments,
IMxGatewayCliClient client,
@@ -2015,6 +2293,15 @@ public static class MxGatewayClientCli
or "add-item"
or "advise"
or "advise-supervisory"
or "unregister"
or "add-buffered-item"
or "set-buffered-update-interval"
or "suspend"
or "activate"
or "write-secured"
or "write-secured2"
or "authenticate-user"
or "archestra-user-to-id"
or "subscribe-bulk"
or "unsubscribe-bulk"
or "read-bulk"
@@ -2078,6 +2365,15 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet advise-supervisory --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet unregister --session-id <id> --server-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet add-buffered-item --session-id <id> --server-handle <n> --item <ref> [--item-context <s>] [--json]");
writer.WriteLine("mxgw-dotnet set-buffered-update-interval --session-id <id> --server-handle <n> --interval-ms <n> [--json]");
writer.WriteLine("mxgw-dotnet suspend --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet activate --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet write-secured --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> --current-user-id <n> [--verifier-user-id <n>] [--json]");
writer.WriteLine("mxgw-dotnet write-secured2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> --current-user-id <n> [--verifier-user-id <n>] [--timestamp <iso>] [--json]");
writer.WriteLine("mxgw-dotnet authenticate-user --session-id <id> --server-handle <n> --verify-user <user> (--verify-user-password <pw> | --verify-user-password-env <ENVVAR>) [--json]");
writer.WriteLine("mxgw-dotnet archestra-user-to-id --session-id <id> --server-handle <n> --user-guid <guid> [--json]");
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]");
@@ -132,6 +132,120 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
[Fact]
public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.WriteSecured,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"write-secured",
"--endpoint", "http://localhost:5000",
"--api-key", "test-api-key",
"--session-id", "session-fixture",
"--server-handle", "12",
"--item-handle", "34",
"--type", "int32",
"--value", "123",
"--current-user-id", "5",
"--verifier-user-id", "6",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind);
Assert.Equal(123, request.Command.WriteSecured.Value.Int32Value);
Assert.Equal(5, request.Command.WriteSecured.CurrentUserId);
Assert.Equal(6, request.Command.WriteSecured.VerifierUserId);
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
/// credential from the flag, and that the credential never appears in stdout/stderr.
/// </summary>
[Fact]
public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
{
const string password = "cli-secret-credential-987";
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AuthenticateUser,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AuthenticateUser = new AuthenticateUserReply { UserId = 4242 },
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"authenticate-user",
"--endpoint", "http://localhost:5000",
"--api-key", "test-api-key",
"--session-id", "session-fixture",
"--server-handle", "12",
"--verify-user", "operator",
"--verify-user-password", password,
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind);
Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser);
Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
Assert.DoesNotContain(password, output.ToString());
Assert.DoesNotContain(password, error.ToString());
}
/// <summary>
/// CLI-04: a surfaced error for authenticate-user must have the credential
/// redacted (never echoed to stderr), mirroring the API-key redaction seam.
/// </summary>
[Fact]
public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
{
const string password = "leaky-credential-value";
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = await MxGatewayClientCli.RunAsync(
[
"authenticate-user",
"--endpoint", "http://localhost:5000",
"--api-key", "test-api-key",
"--session-id", "session-fixture",
"--server-handle", "12",
"--verify-user", "operator",
"--verify-user-password", password,
],
output,
error,
_ => throw new InvalidOperationException($"boom {password}"));
Assert.Equal(1, exitCode);
Assert.DoesNotContain(password, error.ToString());
Assert.Contains("[redacted]", error.ToString());
}
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -223,6 +223,55 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal("session-fixture", request.SessionId);
}
/// <summary>
/// Verifies that a reconnect-replay gap sentinel is surfaced as a typed
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal
/// events pass through unchanged with IsReplayGap false.
/// </summary>
[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<MxEventStreamItem> 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);
}
/// <summary>Verifies that close is explicit and idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -380,6 +429,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);
@@ -0,0 +1,40 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Extension methods that project a raw <see cref="MxEvent"/> stream into the
/// typed <see cref="MxEventStreamItem"/> surface, making the gateway's
/// reconnect-replay gap sentinel observable.
/// </summary>
public static class MxEventStreamExtensions
{
/// <summary>
/// Projects a raw <see cref="MxEvent"/> stream (e.g.
/// <see cref="MxGatewaySession.StreamEventsAsync"/> or
/// <see cref="MxGatewayClient.StreamEventsAsync"/>) into typed
/// <see cref="MxEventStreamItem"/> values. Normal events pass through with
/// <see cref="MxEventStreamItem.IsReplayGap"/> false; the gateway's
/// reconnect-replay gap sentinel is surfaced with
/// <see cref="MxEventStreamItem.IsReplayGap"/> true and
/// <see cref="MxEventStreamItem.ReplayGap"/> populated. The stream is
/// forwarded faithfully — no event is synthesized or dropped.
/// </summary>
/// <param name="source">The raw event stream to wrap.</param>
/// <param name="cancellationToken">Cancellation token for the enumeration.</param>
/// <returns>The same events, each wrapped as an <see cref="MxEventStreamItem"/>.</returns>
public static async IAsyncEnumerable<MxEventStreamItem> AsStreamItemsAsync(
this IAsyncEnumerable<MxEvent> source,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
await foreach (MxEvent gatewayEvent in source
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return MxEventStreamItem.From(gatewayEvent);
}
}
}
@@ -0,0 +1,82 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// One item yielded by the typed event stream. It is either a normal MXAccess
/// <see cref="MxEvent"/> or a reconnect-replay <em>gap sentinel</em>.
/// </summary>
/// <remarks>
/// <para>
/// The gateway emits a single sentinel <see cref="MxEvent"/> at the head of a
/// <c>StreamEvents</c> stream that was resumed via
/// <c>StreamEventsRequest.after_worker_sequence</c> when the requested sequence
/// predates the oldest event still retained in the session replay ring — i.e.
/// events were evicted and cannot be replayed. On that sentinel the
/// <c>MxEvent.replay_gap</c> field is set, <see cref="MxEvent.Family"/> is
/// <see cref="MxEventFamily.Unspecified"/>, the <c>body</c> oneof is unset, and
/// no per-item fields are populated.
/// </para>
/// <para>
/// This wrapper makes that sentinel observable instead of forcing the consumer
/// to inspect the raw <see cref="MxEvent"/>. It never synthesizes or swallows an
/// event: the gap is exactly the gateway's own sentinel, exposed with
/// <see cref="IsReplayGap"/> set and <see cref="ReplayGap"/> populated. Every
/// other event flows through unchanged with <see cref="IsReplayGap"/> false.
/// </para>
/// <para>
/// When <see cref="IsReplayGap"/> is <see langword="true"/> the consumer has
/// missed events and MUST discard local state and re-snapshot. To resume without
/// incurring another gap, reconnect with
/// <c>after_worker_sequence = ReplayGap.OldestAvailableSequence - 1</c>.
/// </para>
/// </remarks>
public sealed class MxEventStreamItem
{
private MxEventStreamItem(MxEvent gatewayEvent, ReplayGap? replayGap)
{
Event = gatewayEvent;
ReplayGap = replayGap;
}
/// <summary>
/// The underlying raw <see cref="MxEvent"/>. For a normal event this is the
/// MXAccess event itself; for a replay-gap item this is the gateway's
/// sentinel event whose only meaningful payload is <see cref="ReplayGap"/>.
/// Never <see langword="null"/>.
/// </summary>
public MxEvent Event { get; }
/// <summary>
/// The reconnect-replay gap payload when this item is a gap sentinel;
/// otherwise <see langword="null"/>. Read
/// <see cref="Contracts.Proto.ReplayGap.RequestedAfterSequence"/> and
/// <see cref="Contracts.Proto.ReplayGap.OldestAvailableSequence"/> to learn
/// which events were lost.
/// </summary>
public ReplayGap? ReplayGap { get; }
/// <summary>
/// <see langword="true"/> when this item is a reconnect-replay gap sentinel:
/// the consumer missed events and must discard local state and re-snapshot.
/// Resume without another gap by reconnecting with
/// <c>after_worker_sequence = ReplayGap.OldestAvailableSequence - 1</c>.
/// </summary>
public bool IsReplayGap => ReplayGap is not null;
/// <summary>
/// Wraps a raw stream <see cref="MxEvent"/> as a typed item, classifying it
/// as a replay-gap sentinel when <c>MxEvent.replay_gap</c> is present.
/// </summary>
/// <param name="gatewayEvent">The raw event from the <c>StreamEvents</c> stream.</param>
/// <returns>A typed item exposing either the normal event or the replay gap.</returns>
internal static MxEventStreamItem From(MxEvent gatewayEvent)
{
ArgumentNullException.ThrowIfNull(gatewayEvent);
// For a message-typed proto3 field, presence is a non-null reference.
return gatewayEvent.ReplayGap is { } replayGap
? new MxEventStreamItem(gatewayEvent, replayGap)
: new MxEventStreamItem(gatewayEvent, replayGap: null);
}
}
@@ -848,6 +848,525 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken);
}
/// <summary>
/// Unregisters a previously registered client from the MXAccess session
/// (MXAccess <c>Unregister</c>), releasing its ServerHandle.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task UnregisterAsync(
int serverHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await UnregisterRawAsync(serverHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Unregisters a previously registered client without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> UnregisterRawAsync(
int serverHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Unregister,
Unregister = new UnregisterCommand { ServerHandle = serverHandle },
},
cancellationToken);
}
/// <summary>
/// Subscribes to supervisory events for an item (MXAccess <c>AdviseSupervisory</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task AdviseSupervisoryAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AdviseSupervisoryRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Subscribes to supervisory events for an item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AdviseSupervisoryRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AdviseSupervisory,
AdviseSupervisory = new AdviseSupervisoryCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Adds a buffered item to the MXAccess session (MXAccess <c>AddBufferedItem</c>),
/// returning an ItemHandle.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="itemContext">Additional context for the item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item handle assigned to the new buffered item.</returns>
public async Task<int> AddBufferedItemAsync(
int serverHandle,
string itemDefinition,
string itemContext,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AddBufferedItemRawAsync(
serverHandle,
itemDefinition,
itemContext,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddBufferedItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
}
/// <summary>
/// Adds a buffered item to the MXAccess session without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="itemContext">Additional context for the item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AddBufferedItemRawAsync(
int serverHandle,
string itemDefinition,
string itemContext,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AddBufferedItem,
AddBufferedItem = new AddBufferedItemCommand
{
ServerHandle = serverHandle,
ItemDefinition = itemDefinition,
ItemContext = itemContext ?? string.Empty,
},
},
cancellationToken);
}
/// <summary>
/// Sets the buffered-item update interval on the MXAccess session
/// (MXAccess <c>SetBufferedUpdateInterval</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task SetBufferedUpdateIntervalAsync(
int serverHandle,
int updateIntervalMilliseconds,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await SetBufferedUpdateIntervalRawAsync(
serverHandle,
updateIntervalMilliseconds,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Sets the buffered-item update interval without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> SetBufferedUpdateIntervalRawAsync(
int serverHandle,
int updateIntervalMilliseconds,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.SetBufferedUpdateInterval,
SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand
{
ServerHandle = serverHandle,
UpdateIntervalMilliseconds = updateIntervalMilliseconds,
},
},
cancellationToken);
}
/// <summary>
/// Suspends updates for an item (MXAccess <c>Suspend</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item's MXSTATUS_PROXY as reported by the worker, or <c>null</c> if the reply omitted it.</returns>
public async Task<MxStatusProxy?> SuspendAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await SuspendRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Suspend?.Status;
}
/// <summary>
/// Suspends updates for an item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> SuspendRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Suspend,
Suspend = new SuspendCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Resumes updates for a suspended item (MXAccess <c>Activate</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item's MXSTATUS_PROXY as reported by the worker, or <c>null</c> if the reply omitted it.</returns>
public async Task<MxStatusProxy?> ActivateAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await ActivateRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Activate?.Status;
}
/// <summary>
/// Resumes updates for a suspended item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> ActivateRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Activate,
Activate = new ActivateCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Writes a secured value to an item on the MXAccess server (MXAccess <c>WriteSecured</c>).
/// </summary>
/// <remarks>
/// MXAccess parity: <c>WriteSecured</c> fails when it is issued before a value-bearing
/// NMX body or before a prior <c>AuthenticateUser</c> + <c>AdviseSupervisory</c>. That
/// native failure is surfaced unchanged — the client does not pre-validate or reorder it.
/// The <paramref name="value"/> is credential-sensitive and must never reach logs; the
/// client mirrors the single-item WriteSecured redaction contract.
/// </remarks>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task WriteSecuredAsync(
int serverHandle,
int itemHandle,
MxValue value,
int currentUserId,
int verifierUserId,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await WriteSecuredRawAsync(
serverHandle,
itemHandle,
value,
currentUserId,
verifierUserId,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Writes a secured value to an item without error checking. See
/// <see cref="WriteSecuredAsync"/> for the parity and redaction contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> WriteSecuredRawAsync(
int serverHandle,
int itemHandle,
MxValue value,
int currentUserId,
int verifierUserId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(value);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.WriteSecured,
WriteSecured = new WriteSecuredCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
CurrentUserId = currentUserId,
VerifierUserId = verifierUserId,
Value = value,
},
},
cancellationToken);
}
/// <summary>
/// Writes a secured value and timestamp to an item (MXAccess <c>WriteSecured2</c>).
/// </summary>
/// <remarks>
/// Same parity and redaction contract as <see cref="WriteSecuredAsync"/>: the native
/// failure that occurs before a value-bearing NMX body or a prior authenticate is
/// surfaced unchanged, and the credential-sensitive <paramref name="value"/> must never
/// reach logs.
/// </remarks>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task WriteSecured2Async(
int serverHandle,
int itemHandle,
MxValue value,
MxValue timestampValue,
int currentUserId,
int verifierUserId,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await WriteSecured2RawAsync(
serverHandle,
itemHandle,
value,
timestampValue,
currentUserId,
verifierUserId,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Writes a secured value and timestamp to an item without error checking. See
/// <see cref="WriteSecured2Async"/> for the parity and redaction contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> WriteSecured2RawAsync(
int serverHandle,
int itemHandle,
MxValue value,
MxValue timestampValue,
int currentUserId,
int verifierUserId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(value);
ArgumentNullException.ThrowIfNull(timestampValue);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.WriteSecured2,
WriteSecured2 = new WriteSecured2Command
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
CurrentUserId = currentUserId,
VerifierUserId = verifierUserId,
Value = value,
TimestampValue = timestampValue,
},
},
cancellationToken);
}
/// <summary>
/// Authenticates an MXAccess verify-user (MXAccess <c>AuthenticateUser</c>), returning
/// the resolved user id used by <c>WriteSecured</c> / <c>WriteSecured2</c>.
/// </summary>
/// <remarks>
/// The <paramref name="verifyUserPassword"/> is a raw MXAccess credential. It is never
/// logged and never placed on the exception path: gateway/MXAccess failures surface only
/// reply-derived diagnostics (kind, HRESULT, MXSTATUS_PROXY), never the request payload.
/// </remarks>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="verifyUser">The user to verify.</param>
/// <param name="verifyUserPassword">The verify-user credential. Never logged.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated user id.</returns>
public async Task<int> AuthenticateUserAsync(
int serverHandle,
string verifyUser,
string verifyUserPassword,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AuthenticateUserRawAsync(
serverHandle,
verifyUser,
verifyUserPassword,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AuthenticateUser?.UserId ?? reply.ReturnValue.Int32Value;
}
/// <summary>
/// Authenticates an MXAccess verify-user without error checking. See
/// <see cref="AuthenticateUserAsync"/> for the credential-handling contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="verifyUser">The user to verify.</param>
/// <param name="verifyUserPassword">The verify-user credential. Never logged.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AuthenticateUserRawAsync(
int serverHandle,
string verifyUser,
string verifyUserPassword,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(verifyUser);
ArgumentNullException.ThrowIfNull(verifyUserPassword);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AuthenticateUser,
AuthenticateUser = new AuthenticateUserCommand
{
ServerHandle = serverHandle,
VerifyUser = verifyUser,
VerifyUserPassword = verifyUserPassword,
},
},
cancellationToken);
}
/// <summary>
/// Resolves an ArchestrA user GUID to its MXAccess user id
/// (MXAccess <c>ArchestrAUserToId</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="userIdGuid">The ArchestrA user GUID to resolve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved MXAccess user id.</returns>
public async Task<int> ArchestraUserToIdAsync(
int serverHandle,
string userIdGuid,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await ArchestraUserToIdRawAsync(serverHandle, userIdGuid, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.ArchestraUserToId?.UserId ?? reply.ReturnValue.Int32Value;
}
/// <summary>
/// Resolves an ArchestrA user GUID to its MXAccess user id without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="userIdGuid">The ArchestrA user GUID to resolve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> ArchestraUserToIdRawAsync(
int serverHandle,
string userIdGuid,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userIdGuid);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.ArchestraUserToId,
ArchestraUserToId = new ArchestrAUserToIdCommand
{
ServerHandle = serverHandle,
UserIdGuid = userIdGuid,
},
},
cancellationToken);
}
/// <summary>
/// Invokes an MXAccess command on this session.
/// </summary>
@@ -881,6 +1400,34 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken);
}
/// <summary>
/// Streams events as typed <see cref="MxEventStreamItem"/> values, surfacing
/// the gateway's reconnect-replay gap sentinel as an observable, typed signal.
/// </summary>
/// <remarks>
/// When resuming with a stale <paramref name="afterWorkerSequence"/> (older than
/// the oldest event still retained in the session replay ring), the gateway emits
/// a single gap sentinel at the head of the stream. It arrives here as an item
/// with <see cref="MxEventStreamItem.IsReplayGap"/> true and
/// <see cref="MxEventStreamItem.ReplayGap"/> populated, meaning the consumer
/// missed events and MUST discard local state and re-snapshot. To resume without
/// incurring another gap, reconnect with
/// <c>afterWorkerSequence = item.ReplayGap.OldestAvailableSequence - 1</c>.
/// All other events pass through with <see cref="MxEventStreamItem.IsReplayGap"/>
/// false. Use <see cref="StreamEventsAsync"/> when raw generated
/// <see cref="MxEvent"/> messages are needed instead.
/// </remarks>
/// <param name="afterWorkerSequence">The sequence number to stream from. Defaults to 0.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of typed event items.</returns>
public IAsyncEnumerable<MxEventStreamItem> StreamEventItemsAsync(
ulong afterWorkerSequence = 0,
CancellationToken cancellationToken = default)
{
return StreamEventsAsync(afterWorkerSequence, cancellationToken)
.AsStreamItemsAsync(cancellationToken);
}
/// <summary>
/// Closes the session and releases resources.
/// </summary>
@@ -19,6 +19,7 @@
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
<Version>0.1.2</Version>
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<!-- Only the shipped library generates XML docs (matching src/Contracts). The Cli and
+61 -16
View File
@@ -84,7 +84,9 @@ true` to verify against the OS/system trust roots without pinning. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
`Client.OpenSession` returns a `Session` with helpers for `Register`,
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
`AddItem`, `AddItem2`, `Advise`, `AdviseSupervisory`, `Write`, `WriteSecured`,
`WriteSecured2`, `AuthenticateUser`, `ArchestrAUserToId`, `AddBufferedItem`,
`SetBufferedUpdateInterval`, `Suspend`, `Activate`, `Events`, and `Close`. Prefer
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
returned subscription owns cancellation and exposes `Close` for deterministic
goroutine cleanup. Raw protobuf messages remain available through the
@@ -92,6 +94,44 @@ goroutine cleanup. Raw protobuf messages remain available through the
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply.
### Reconnect-replay gap
Each `EventResult` carries exactly one of `Event`, `ReplayGap`, or `Err`. When
you resume a stream with `EventsAfter`/`SubscribeEventsAfter` and a non-zero
`afterWorkerSequence`, the gateway replays buffered events from that point. If
the requested sequence predates the oldest event still retained in its replay
ring, it delivers a single **replay-gap sentinel** at the head of the resumed
stream: `res.ReplayGap` is non-nil (`res.Event` is nil, `res.IsReplayGap()` is
true) and normal events follow it.
A gap means events were lost, so any locally cached tag/alarm state is now
stale. On seeing it, discard your cached state and re-snapshot. To resume
without provoking another gap, reconnect from just before the oldest retained
sequence:
```go
for res := range events {
switch {
case res.Err != nil:
// terminal: stream ended (see ErrSlowConsumer for the overflow case)
return res.Err
case res.IsReplayGap():
gap := res.ReplayGap
log.Printf("replay gap: requested after %d, oldest available %d; re-snapshotting",
gap.GetRequestedAfterSequence(), gap.GetOldestAvailableSequence())
resnapshot()
// to resume cleanly, reconnect with:
// session.EventsAfter(ctx, gap.GetOldestAvailableSequence()-1)
default:
handle(res.Event)
}
}
```
The gateway sets `ReplayGap` only on `StreamEvents` results (never on a fresh,
non-resumed stream and never on `DrainEvents`). The client makes the gateway's
sentinel typed and observable; it never synthesizes or swallows it.
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
@@ -113,29 +153,32 @@ still need the write attributed to a user id, you must first advise the item
supervisory and then pass that user id on the write. Without the supervisory
advise the `userID` on a plain write is ignored.
The session exposes `Advise`/`UnAdvise` but not supervisory advise, so send it
through the generic command channel:
The session exposes a typed `AdviseSupervisory` helper alongside `Advise`/`UnAdvise`:
```go
_, err := client.Invoke(ctx, &pb.MxCommandRequest{
SessionId: session.ID(),
Command: &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
Payload: &pb.MxCommand_AdviseSupervisory{
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
},
},
},
})
err := session.AdviseSupervisory(ctx, serverHandle, itemHandle)
// ...
err = session.Write(ctx, serverHandle, itemHandle, value, userID)
```
The CLI exposes the same command as `advise-supervisory`, and `write`
takes `-user-id`.
### Secured writes and user authentication
The verified/secured path has typed single-item helpers too:
`AuthenticateUser(ctx, serverHandle, verifyUser, verifyUserPassword)` returns the
resolved MXAccess user id, and `WriteSecured` / `WriteSecured2` issue the secured
write. Credentials passed to `AuthenticateUser`, and the string content of a
`WriteSecured`/`WriteSecured2` value, are kept out of any error the client
surfaces (they route through the same redaction seam as the API key) and are
never logged — callers must likewise keep them out of their own logs. MXAccess
parity holds: a `WriteSecured` issued without a matching prior `AuthenticateUser`
and supervisory advise fails natively, and that failure is surfaced unchanged
rather than pre-empted. The CLI exposes `authenticate-user` (credential via
`-password-env`, default `MXGATEWAY_VERIFY_PASSWORD`, or `-password`) and
`write-secured`.
### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an
@@ -340,6 +383,8 @@ Every subcommand wired into the CLI. All accept the common flags
| `unsubscribe-bulk` | Unadvise many item handles in one call. |
| `read-bulk` | Read snapshots for many item handles in one call. |
| `write` | Write one value (`-type`, `-value`). |
| `write-secured` | Secured single-item write (`-current-user-id`, `-verifier-user-id`, `-type`, `-value`). |
| `authenticate-user` | Authenticate a user, printing the resolved user id (`-verify-user`, `-password-env`/`-password`). |
| `write-bulk` | Write many values (`-item-handles`, `-values`, counts must match). |
| `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). |
| `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). |
+86 -14
View File
@@ -21,7 +21,6 @@ import (
"syscall"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect"
@@ -90,6 +89,10 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
return runAdvise(ctx, args[1:], stdout, stderr)
case "advise-supervisory":
return runAdviseSupervisory(ctx, args[1:], stdout, stderr)
case "write-secured":
return runWriteSecured(ctx, args[1:], stdout, stderr)
case "authenticate-user":
return runAuthenticateUser(ctx, args[1:], stdout, stderr)
case "subscribe-bulk":
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
case "unsubscribe-bulk":
@@ -383,21 +386,90 @@ func runAdviseSupervisory(ctx context.Context, args []string, stdout, stderr io.
}
defer client.Close()
reply, err := client.Invoke(ctx, &pb.MxCommandRequest{
SessionId: *sessionID,
Command: &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
Payload: &pb.MxCommand_AdviseSupervisory{
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
ServerHandle: int32(*serverHandle),
ItemHandle: int32(*itemHandle),
},
},
},
})
session := mxgateway.NewSessionForID(client, *sessionID)
reply, err := session.AdviseSupervisoryRaw(ctx, int32(*serverHandle), int32(*itemHandle))
return writeCommandOutput(stdout, *jsonOutput, "advise-supervisory", options, reply, err)
}
func runWriteSecured(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("write-secured", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
itemHandle := flags.Int("item-handle", 0, "MXAccess item handle")
currentUserID := flags.Int("current-user-id", 0, "MXAccess current user id")
verifierUserID := flags.Int("verifier-user-id", 0, "MXAccess verifier user id")
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
valueText := flags.String("value", "", "value text")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" {
return errors.New("session-id is required")
}
value, err := parseValue(*valueType, *valueText)
if err != nil {
return err
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
reply, err := session.WriteSecuredRaw(ctx, int32(*serverHandle), int32(*itemHandle), int32(*currentUserID), int32(*verifierUserID), value)
return writeCommandOutput(stdout, *jsonOutput, "write-secured", options, reply, err)
}
func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("authenticate-user", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
verifyUser := flags.String("verify-user", "", "MXAccess user to authenticate")
// The credential is never accepted echoed on the command line by default:
// prefer the environment variable so it stays out of shell history and the
// process table. The -password flag remains for non-interactive scripting.
password := flags.String("password", "", "verify-user password (prefer -password-env)")
passwordEnv := flags.String("password-env", "MXGATEWAY_VERIFY_PASSWORD", "environment variable containing the verify-user password")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" {
return errors.New("session-id is required")
}
if *verifyUser == "" {
return errors.New("verify-user is required")
}
resolvedPassword := *password
if resolvedPassword == "" && *passwordEnv != "" {
resolvedPassword = os.Getenv(*passwordEnv)
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
// The raw reply carries only the resolved user id, never the credential, so
// writeCommandOutput can render it as-is; the credential is additionally
// scrubbed from any surfaced error by AuthenticateUserRaw.
reply, err := session.AuthenticateUserRaw(ctx, int32(*serverHandle), *verifyUser, resolvedPassword)
return writeCommandOutput(stdout, *jsonOutput, "authenticate-user", options, reply, err)
}
func runSubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -1295,7 +1367,7 @@ type protojsonMessage interface {
}
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured|write-secured-bulk|write-secured2-bulk|authenticate-user|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
}
// batchEOR is the end-of-result sentinel emitted to stdout after every command
+30
View File
@@ -568,6 +568,36 @@ func TestRunAdviseSupervisoryRequiresSessionID(t *testing.T) {
}
}
// TestRunWriteSecuredRequiresSessionID pins the write-secured session-id guard so
// it fails fast before dialing.
func TestRunWriteSecuredRequiresSessionID(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{"write-secured", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
if err == nil || !strings.Contains(err.Error(), "session-id is required") {
t.Fatalf("write-secured without -session-id error = %v", err)
}
}
// TestRunAuthenticateUserRequiresVerifyUser pins that authenticate-user fails fast
// (before dialing) when the user is absent, and never echoes any credential in
// the guard error.
func TestRunAuthenticateUserRequiresVerifyUser(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{
"authenticate-user",
"-session-id", "s1",
"-password", "hunter2-password",
"-plaintext",
"-api-key", "test",
}, &stdout, &stderr)
if err == nil || !strings.Contains(err.Error(), "verify-user is required") {
t.Fatalf("authenticate-user without -verify-user error = %v", err)
}
if strings.Contains(err.Error(), "hunter2-password") {
t.Fatalf("authenticate-user guard error leaked the credential: %v", err)
}
}
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
// guard so a write-bulk with unequal item-handles / values counts fails fast
// before any dial.
@@ -200,6 +200,63 @@ func TestEventsSlowConsumerYieldsErrSlowConsumerBeforeClose(t *testing.T) {
}
}
func TestEventsSurfacesReplayGapSentinelAsTypedSignal(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
streamReplayGap: &pb.ReplayGap{
RequestedAfterSequence: 5,
OldestAvailableSequence: 42,
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events, err := session.EventsAfter(ctx, 5)
if err != nil {
t.Fatalf("EventsAfter() error = %v", err)
}
<-fake.streamStarted
// First result must be the typed replay-gap signal, not a normal event.
first := <-events
if first.Err != nil {
t.Fatalf("first result error = %v", first.Err)
}
if !first.IsReplayGap() {
t.Fatalf("first result IsReplayGap() = false, want true")
}
if first.Event != nil {
t.Fatalf("replay-gap result carried a non-nil Event %+v; want the sentinel to clear Event", first.Event)
}
if got := first.ReplayGap.GetRequestedAfterSequence(); got != 5 {
t.Fatalf("ReplayGap.RequestedAfterSequence = %d, want 5", got)
}
if got := first.ReplayGap.GetOldestAvailableSequence(); got != 42 {
t.Fatalf("ReplayGap.OldestAvailableSequence = %d, want 42", got)
}
// Normal events after the sentinel are unaffected: Event set, ReplayGap nil.
second := <-events
if second.Err != nil {
t.Fatalf("second result error = %v", second.Err)
}
if second.IsReplayGap() {
t.Fatalf("second result IsReplayGap() = true, want false for a normal event")
}
if second.Event == nil {
t.Fatal("second result Event = nil, want a normal event")
}
if got := second.Event.GetWorkerSequence(); got != 1 {
t.Fatalf("normal event worker sequence = %d, want 1", got)
}
if second.Event.GetFamily() != pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE {
t.Fatalf("normal event family = %s, want ON_DATA_CHANGE", second.Event.GetFamily())
}
}
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
@@ -643,6 +700,7 @@ type fakeGatewayServer struct {
streamStarted chan struct{}
streamDone chan struct{}
streamEventCount int
streamReplayGap *pb.ReplayGap
invokeReply *pb.MxCommandReply
invokeRequest *pb.MxCommandRequest
}
@@ -691,6 +749,16 @@ func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grp
if s.streamStarted != nil {
close(s.streamStarted)
}
if s.streamReplayGap != nil {
// Emit the reconnect-replay gap sentinel at the head of the resumed
// stream: family UNSPECIFIED, body unset, only replay_gap populated.
if err := stream.Send(&pb.MxEvent{
SessionId: req.GetSessionId(),
ReplayGap: s.streamReplayGap,
}); err != nil {
return err
}
}
eventCount := s.streamEventCount
if eventCount == 0 {
eventCount = 1
+58
View File
@@ -3,10 +3,68 @@ package mxgateway
import (
"errors"
"fmt"
"strings"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
)
// redactedSecretMarker is the placeholder substituted for credential material in
// surfaced error text. It matches the marker used by RedactAPIKey so the client
// presents one consistent redaction shape everywhere secrets could otherwise
// leak.
const redactedSecretMarker = "<redacted>"
// secretRedactingError wraps a typed error so any occurrence of a known
// credential in the underlying message is replaced with redactedSecretMarker in
// the surfaced text. Unwrap still exposes the wrapped error, so errors.As /
// errors.Is continue to reach the underlying MxAccessError, CommandError, or
// GatewayError. This is the seam that keeps AuthenticateUser credentials and
// WriteSecured/WriteSecured2 payload strings out of any error a caller might log,
// even if a gateway diagnostic message were to echo them back.
type secretRedactingError struct {
err error
secrets []string
}
// Error returns the wrapped error's message with every non-empty secret redacted.
func (e *secretRedactingError) Error() string {
if e == nil || e.err == nil {
return ""
}
message := e.err.Error()
for _, secret := range e.secrets {
if secret != "" {
message = strings.ReplaceAll(message, secret, redactedSecretMarker)
}
}
return message
}
// Unwrap returns the wrapped error so typed-error inspection still works through
// the redaction wrapper.
func (e *secretRedactingError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
// redactSecrets wraps err so any occurrence of a non-empty secret in the surfaced
// message is redacted, while errors.As / errors.Is still reach the wrapped typed
// error. It returns nil unchanged and skips wrapping when no non-empty secret is
// supplied, so non-secret-bearing calls keep their original error verbatim.
func redactSecrets(err error, secrets ...string) error {
if err == nil {
return nil
}
for _, secret := range secrets {
if secret != "" {
return &secretRedactingError{err: err, secrets: secrets}
}
}
return err
}
// ErrSlowConsumer is the terminal error sent on the Events/EventsAfter
// (cancel-when-full) path when the buffered results channel overflows because
// the consumer fell behind. It is delivered as the final EventResult.Err before
+307 -3
View File
@@ -27,14 +27,39 @@ const eventBufferSize = 16
// non-blockingly on overflow, even when all data slots are full.
const eventBufferReservedSlots = 1
// EventResult carries either the next ordered event or a terminal stream error.
// EventResult carries the next ordered event, a replay-gap signal, or a
// terminal stream error. Exactly one of Event, ReplayGap, or Err is set on any
// delivered result.
type EventResult struct {
// Event is the next event from the stream when Err is nil.
// Event is the next MXAccess event from the stream when both ReplayGap and
// Err are nil.
Event *MxEvent
// ReplayGap, when non-nil, is the gateway's reconnect-replay gap sentinel: it
// is delivered at the head of a resumed stream (one opened with a non-zero
// after_worker_sequence via EventsAfter/SubscribeEventsAfter) when the
// requested sequence predates the oldest event still retained in the replay
// ring, so the events in between were lost.
//
// It is a non-terminal, observable signal — the stream continues with normal
// events after it, and Event is nil on a gap result so a gap is never
// mistaken for a normal MXAccess event. On seeing a gap the consumer must
// discard any locally cached tag/alarm state and re-snapshot. To resume
// cleanly (without provoking another gap), reconnect with EventsAfter using
// afterWorkerSequence = ReplayGap.GetOldestAvailableSequence() - 1.
//
// The gateway sets ReplayGap only on StreamEvents results, never on a normal
// (non-resumed) stream and never on DrainEvents.
ReplayGap *ReplayGap
// Err is the terminal stream error; when non-nil no further results follow.
Err error
}
// IsReplayGap reports whether this result carries the gateway's reconnect-replay
// gap sentinel rather than a normal event or a terminal error.
func (r EventResult) IsReplayGap() bool {
return r.ReplayGap != nil
}
// EventSubscription owns a running gateway event stream.
type EventSubscription struct {
results <-chan EventResult
@@ -681,6 +706,277 @@ func (s *Session) Write2Raw(ctx context.Context, serverHandle, itemHandle int32,
})
}
// AdviseSupervisory invokes MXAccess AdviseSupervisory, advising an item on the
// supervisory (as opposed to runtime) data path.
func (s *Session) AdviseSupervisory(ctx context.Context, serverHandle, itemHandle int32) error {
_, err := s.AdviseSupervisoryRaw(ctx, serverHandle, itemHandle)
return err
}
// AdviseSupervisoryRaw invokes MXAccess AdviseSupervisory and returns the raw reply.
func (s *Session) AdviseSupervisoryRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
Payload: &pb.MxCommand_AdviseSupervisory{
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
},
},
})
}
// WriteSecured invokes MXAccess WriteSecured (secured single-item write).
//
// The value is credential-sensitive: callers must not log it, and any error this
// call surfaces has the value's string content redacted (see WriteSecuredRaw).
// MXAccess parity is preserved — WriteSecured legitimately fails when it is not
// preceded by a matching AuthenticateUser + AdviseSupervisory or when the body
// carries no value; that native failure is surfaced as-is, never pre-empted or
// reordered by this client.
func (s *Session) WriteSecured(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value *MxValue) error {
_, err := s.WriteSecuredRaw(ctx, serverHandle, itemHandle, currentUserID, verifierUserID, value)
return err
}
// WriteSecuredRaw invokes MXAccess WriteSecured and returns the raw reply. Any
// surfaced error is routed through the client's secret-redaction seam so a string
// write value never appears in error text.
func (s *Session) WriteSecuredRaw(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value *MxValue) (*MxCommandReply, error) {
if value == nil {
return nil, errors.New("mxgateway: write-secured value is required")
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED,
Payload: &pb.MxCommand_WriteSecured{
WriteSecured: &pb.WriteSecuredCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
CurrentUserId: currentUserID,
VerifierUserId: verifierUserID,
Value: value,
},
},
})
return reply, redactSecrets(err, stringSecrets(value)...)
}
// WriteSecured2 invokes MXAccess WriteSecured2 (secured, timestamped single-item write).
//
// Like WriteSecured, the value is credential-sensitive and its string content is
// scrubbed from any surfaced error. Native parity failures (missing prior
// AuthenticateUser/AdviseSupervisory, value-less body) are surfaced unchanged.
func (s *Session) WriteSecured2(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value, timestampValue *MxValue) error {
_, err := s.WriteSecured2Raw(ctx, serverHandle, itemHandle, currentUserID, verifierUserID, value, timestampValue)
return err
}
// WriteSecured2Raw invokes MXAccess WriteSecured2 and returns the raw reply. Any
// surfaced error is routed through the client's secret-redaction seam.
func (s *Session) WriteSecured2Raw(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value, timestampValue *MxValue) (*MxCommandReply, error) {
if value == nil {
return nil, errors.New("mxgateway: write-secured2 value is required")
}
if timestampValue == nil {
return nil, errors.New("mxgateway: write-secured2 timestamp value is required")
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2,
Payload: &pb.MxCommand_WriteSecured2{
WriteSecured2: &pb.WriteSecured2Command{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
CurrentUserId: currentUserID,
VerifierUserId: verifierUserID,
Value: value,
TimestampValue: timestampValue,
},
},
})
return reply, redactSecrets(err, stringSecrets(value)...)
}
// AuthenticateUser invokes MXAccess AuthenticateUser and returns the resolved
// MXAccess user id.
//
// verifyUserPassword is a raw MXAccess credential: this client never logs it and
// scrubs it from any error it surfaces (see AuthenticateUserRaw). Callers must
// likewise keep it out of their own logs, metrics, and diagnostics.
func (s *Session) AuthenticateUser(ctx context.Context, serverHandle int32, verifyUser, verifyUserPassword string) (int32, error) {
reply, err := s.AuthenticateUserRaw(ctx, serverHandle, verifyUser, verifyUserPassword)
if err != nil {
return 0, err
}
if reply.GetAuthenticateUser() != nil {
return reply.GetAuthenticateUser().GetUserId(), nil
}
return reply.GetReturnValue().GetInt32Value(), nil
}
// AuthenticateUserRaw invokes MXAccess AuthenticateUser and returns the raw
// reply. The credential is scrubbed from any surfaced error via the client's
// secret-redaction seam, so even a gateway diagnostic echoing the password back
// cannot leak it through this call's error.
func (s *Session) AuthenticateUserRaw(ctx context.Context, serverHandle int32, verifyUser, verifyUserPassword string) (*MxCommandReply, error) {
if verifyUser == "" {
return nil, errors.New("mxgateway: verify user is required")
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
Payload: &pb.MxCommand_AuthenticateUser{
AuthenticateUser: &pb.AuthenticateUserCommand{
ServerHandle: serverHandle,
VerifyUser: verifyUser,
VerifyUserPassword: verifyUserPassword,
},
},
})
return reply, redactSecrets(err, verifyUserPassword)
}
// ArchestrAUserToId invokes MXAccess ArchestrAUserToId, resolving an ArchestrA
// user GUID to its MXAccess integer user id.
func (s *Session) ArchestrAUserToId(ctx context.Context, serverHandle int32, userIDGuid string) (int32, error) {
reply, err := s.ArchestrAUserToIdRaw(ctx, serverHandle, userIDGuid)
if err != nil {
return 0, err
}
if reply.GetArchestraUserToId() != nil {
return reply.GetArchestraUserToId().GetUserId(), nil
}
return reply.GetReturnValue().GetInt32Value(), nil
}
// ArchestrAUserToIdRaw invokes MXAccess ArchestrAUserToId and returns the raw reply.
func (s *Session) ArchestrAUserToIdRaw(ctx context.Context, serverHandle int32, userIDGuid string) (*MxCommandReply, error) {
if userIDGuid == "" {
return nil, errors.New("mxgateway: user id GUID is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID,
Payload: &pb.MxCommand_ArchestraUserToId{
ArchestraUserToId: &pb.ArchestrAUserToIdCommand{
ServerHandle: serverHandle,
UserIdGuid: userIDGuid,
},
},
})
}
// AddBufferedItem invokes MXAccess AddBufferedItem and returns the item handle.
func (s *Session) AddBufferedItem(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (int32, error) {
reply, err := s.AddBufferedItemRaw(ctx, serverHandle, itemDefinition, itemContext)
if err != nil {
return 0, err
}
if reply.GetAddBufferedItem() != nil {
return reply.GetAddBufferedItem().GetItemHandle(), nil
}
return reply.GetReturnValue().GetInt32Value(), nil
}
// AddBufferedItemRaw invokes MXAccess AddBufferedItem and returns the raw reply.
func (s *Session) AddBufferedItemRaw(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (*MxCommandReply, error) {
if itemDefinition == "" {
return nil, errors.New("mxgateway: item definition is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_BUFFERED_ITEM,
Payload: &pb.MxCommand_AddBufferedItem{
AddBufferedItem: &pb.AddBufferedItemCommand{
ServerHandle: serverHandle,
ItemDefinition: itemDefinition,
ItemContext: itemContext,
},
},
})
}
// SetBufferedUpdateInterval invokes MXAccess SetBufferedUpdateInterval.
func (s *Session) SetBufferedUpdateInterval(ctx context.Context, serverHandle, updateIntervalMilliseconds int32) error {
_, err := s.SetBufferedUpdateIntervalRaw(ctx, serverHandle, updateIntervalMilliseconds)
return err
}
// SetBufferedUpdateIntervalRaw invokes MXAccess SetBufferedUpdateInterval and returns the raw reply.
func (s *Session) SetBufferedUpdateIntervalRaw(ctx context.Context, serverHandle, updateIntervalMilliseconds int32) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL,
Payload: &pb.MxCommand_SetBufferedUpdateInterval{
SetBufferedUpdateInterval: &pb.SetBufferedUpdateIntervalCommand{
ServerHandle: serverHandle,
UpdateIntervalMilliseconds: updateIntervalMilliseconds,
},
},
})
}
// Suspend invokes MXAccess Suspend and returns the resulting item status.
func (s *Session) Suspend(ctx context.Context, serverHandle, itemHandle int32) (*MxStatusProxy, error) {
reply, err := s.SuspendRaw(ctx, serverHandle, itemHandle)
if err != nil {
return nil, err
}
return reply.GetSuspend().GetStatus(), nil
}
// SuspendRaw invokes MXAccess Suspend and returns the raw reply.
func (s *Session) SuspendRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUSPEND,
Payload: &pb.MxCommand_Suspend{
Suspend: &pb.SuspendCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
},
},
})
}
// Activate invokes MXAccess Activate and returns the resulting item status.
func (s *Session) Activate(ctx context.Context, serverHandle, itemHandle int32) (*MxStatusProxy, error) {
reply, err := s.ActivateRaw(ctx, serverHandle, itemHandle)
if err != nil {
return nil, err
}
return reply.GetActivate().GetStatus(), nil
}
// ActivateRaw invokes MXAccess Activate and returns the raw reply.
func (s *Session) ActivateRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ACTIVATE,
Payload: &pb.MxCommand_Activate{
Activate: &pb.ActivateCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
},
},
})
}
// stringSecrets collects the non-empty string content of the given values so it
// can be scrubbed from surfaced errors. Only string-typed MxValues carry
// scrubable text; non-string values (numbers, timestamps, arrays) contribute
// nothing.
func stringSecrets(values ...*MxValue) []string {
var secrets []string
for _, value := range values {
if value == nil {
continue
}
if stringValue, ok := value.GetKind().(*pb.MxValue_StringValue); ok && stringValue.StringValue != "" {
secrets = append(secrets, stringValue.StringValue)
}
}
return secrets
}
// Events streams ordered session events until the server ends the stream,
// context cancellation stops Recv, or a terminal error is sent.
//
@@ -736,7 +1032,15 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
for {
event, err := stream.Recv()
if err == nil {
if !sendEventResult(streamCtx, results, EventResult{Event: event}, cancelWhenResultBufferFull, cancel) {
result := EventResult{Event: event}
// The gateway marks a reconnect-replay gap with a sentinel MxEvent
// carrying replay_gap (family UNSPECIFIED, body unset). Surface it
// as a distinct typed signal rather than a normal event: clear
// Event so consumers never process the sentinel as a data change.
if gap := event.GetReplayGap(); gap != nil {
result = EventResult{ReplayGap: gap}
}
if !sendEventResult(streamCtx, results, result, cancelWhenResultBufferFull, cancel) {
return
}
continue
@@ -0,0 +1,224 @@
package mxgateway
import (
"context"
"errors"
"strings"
"testing"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
)
// TestAdviseSupervisoryBuildsCommandAndExposesRawReply pins that the promoted
// typed helper emits an ADVISE_SUPERVISORY command carrying the server/item
// handles and returns the raw reply.
func TestAdviseSupervisoryBuildsCommandAndExposesRawReply(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
reply, err := session.AdviseSupervisoryRaw(context.Background(), 12, 34)
if err != nil {
t.Fatalf("AdviseSupervisoryRaw() error = %v", err)
}
if reply.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY {
t.Fatalf("reply kind = %s", reply.GetKind())
}
cmd := fake.invokeRequest.GetCommand()
if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY {
t.Fatalf("command kind = %s", cmd.GetKind())
}
if cmd.GetAdviseSupervisory().GetServerHandle() != 12 || cmd.GetAdviseSupervisory().GetItemHandle() != 34 {
t.Fatalf("advise-supervisory handles = (%d, %d), want (12, 34)",
cmd.GetAdviseSupervisory().GetServerHandle(), cmd.GetAdviseSupervisory().GetItemHandle())
}
// The error-returning wrapper drops the reply but must not error on success.
if err := session.AdviseSupervisory(context.Background(), 12, 34); err != nil {
t.Fatalf("AdviseSupervisory() error = %v", err)
}
}
// TestWriteSecuredSurfacesNativeFailureWithoutPriorAuthenticate pins MXAccess
// parity: WriteSecured issued without a preceding AuthenticateUser is rejected
// natively, and the client surfaces that failure as a typed MxAccessError rather
// than pre-validating or reordering. The write value is also kept out of the
// surfaced error text.
func TestWriteSecuredSurfacesNativeFailureWithoutPriorAuthenticate(t *testing.T) {
hresult := int32(-2147024891) // E_ACCESSDENIED
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED,
Hresult: &hresult,
DiagnosticMessage: "WriteSecured requires a prior AuthenticateUser",
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
Message: "MXAccess failed",
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
securedValue := "supersecret-payload"
err := session.WriteSecured(context.Background(), 12, 34, 0, 0, StringValue(securedValue))
var mxErr *MxAccessError
if !errors.As(err, &mxErr) {
t.Fatalf("error %T does not support errors.As(*MxAccessError); err = %v", err, err)
}
if strings.Contains(err.Error(), securedValue) {
t.Fatalf("surfaced error leaked the secured payload: %q", err.Error())
}
// The command must carry the secured fields verbatim (parity: unaltered).
cmd := fake.invokeRequest.GetCommand()
if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED {
t.Fatalf("command kind = %s", cmd.GetKind())
}
if cmd.GetWriteSecured().GetValue().GetStringValue() != securedValue {
t.Fatalf("wire value = %q, want %q", cmd.GetWriteSecured().GetValue().GetStringValue(), securedValue)
}
}
// TestWriteSecuredRejectsNilValueWithoutRoundTrip pins the client-side required
// guard, which never echoes the (absent) value.
func TestWriteSecuredRejectsNilValue(t *testing.T) {
fake := &fakeGatewayServer{}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
if err := session.WriteSecured(context.Background(), 1, 2, 0, 0, nil); err == nil ||
!strings.Contains(err.Error(), "write-secured value is required") {
t.Fatalf("WriteSecured(nil value) error = %v", err)
}
}
// TestAuthenticateUserReturnsUserIDOnHappyPath pins the typed helper unpacking of
// AuthenticateUserReply.user_id and that the credential is carried on the wire
// but never surfaced.
func TestAuthenticateUserReturnsUserIDOnHappyPath(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
Payload: &pb.MxCommandReply_AuthenticateUser{
AuthenticateUser: &pb.AuthenticateUserReply{UserId: 4242},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
userID, err := session.AuthenticateUser(context.Background(), 12, "operator", "hunter2-password")
if err != nil {
t.Fatalf("AuthenticateUser() error = %v", err)
}
if userID != 4242 {
t.Fatalf("user id = %d, want 4242", userID)
}
cmd := fake.invokeRequest.GetCommand()
if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER {
t.Fatalf("command kind = %s", cmd.GetKind())
}
if cmd.GetAuthenticateUser().GetVerifyUser() != "operator" {
t.Fatalf("verify user = %q, want operator", cmd.GetAuthenticateUser().GetVerifyUser())
}
if cmd.GetAuthenticateUser().GetVerifyUserPassword() != "hunter2-password" {
t.Fatalf("password not carried to the wire verbatim")
}
}
// TestAuthenticateUserScrubsCredentialFromSurfacedError proves the redaction
// seam: even when the gateway diagnostic message echoes the credential back, the
// surfaced error redacts it while the typed MxAccessError remains reachable via
// errors.As.
func TestAuthenticateUserScrubsCredentialFromSurfacedError(t *testing.T) {
password := "hunter2-password"
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
DiagnosticMessage: "authentication failed for password " + password,
// Message is intentionally left empty so MxAccessError.Error() falls
// through to the diagnostic message — the free-text field that could
// otherwise echo the credential back to a caller's log.
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
_, err := session.AuthenticateUser(context.Background(), 12, "operator", password)
if err == nil {
t.Fatal("AuthenticateUser() returned no error on native failure")
}
if strings.Contains(err.Error(), password) {
t.Fatalf("surfaced error leaked the credential: %q", err.Error())
}
if !strings.Contains(err.Error(), redactedSecretMarker) {
t.Fatalf("surfaced error missing redaction marker: %q", err.Error())
}
var mxErr *MxAccessError
if !errors.As(err, &mxErr) {
t.Fatalf("redaction wrapper broke errors.As(*MxAccessError); err = %v", err)
}
}
// TestAuthenticateUserRequiresVerifyUser pins the client-side required guard.
func TestAuthenticateUserRequiresVerifyUser(t *testing.T) {
fake := &fakeGatewayServer{}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
if _, err := session.AuthenticateUser(context.Background(), 12, "", "pw"); err == nil ||
!strings.Contains(err.Error(), "verify user is required") {
t.Fatalf("AuthenticateUser(empty user) error = %v", err)
}
}
// TestSuspendActivateReturnStatus covers two Phase 2 helpers that unpack an
// MxStatusProxy from their dedicated reply arms.
func TestSuspendActivateReturnStatus(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUSPEND,
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
Payload: &pb.MxCommandReply_Suspend{
Suspend: &pb.SuspendReply{Status: &pb.MxStatusProxy{Success: 1, DiagnosticText: "suspended"}},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
status, err := session.Suspend(context.Background(), 12, 34)
if err != nil {
t.Fatalf("Suspend() error = %v", err)
}
if status.GetDiagnosticText() != "suspended" {
t.Fatalf("status diagnostic = %q, want suspended", status.GetDiagnosticText())
}
if fake.invokeRequest.GetCommand().GetSuspend().GetItemHandle() != 34 {
t.Fatalf("suspend item handle = %d, want 34", fake.invokeRequest.GetCommand().GetSuspend().GetItemHandle())
}
}
+6
View File
@@ -30,6 +30,12 @@ type (
MxCommand = pb.MxCommand
// MxEvent is one ordered event delivered on a session event stream.
MxEvent = pb.MxEvent
// ReplayGap is the gateway sentinel payload signalling that a resumed event
// stream skipped past the oldest event still retained in the replay ring.
// RequestedAfterSequence is the after_worker_sequence the client resumed
// from; OldestAvailableSequence is the oldest sequence the gateway can still
// replay. See EventResult.ReplayGap for consumption guidance.
ReplayGap = pb.ReplayGap
// MxValue is the protobuf representation of an MXAccess value.
MxValue = pb.MxValue
// Value is an alias for MxValue retained for symmetry with other clients.
+3 -3
View File
@@ -1,9 +1,9 @@
package mxgateway
const (
// ClientVersion identifies this Go client scaffold before package releases
// assign semantic versions.
ClientVersion = "0.1.0-dev"
// ClientVersion is the released semantic version of this Go client module.
// Keep it in sync with the module tag applied by scripts/tag-go-module.ps1.
ClientVersion = "0.1.2"
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
// in the shared .NET contracts.
+2 -2
View File
@@ -31,8 +31,8 @@ Alternative Maven layout is acceptable if the repo standardizes on Maven.
Target Java:
- Java 21 recommended.
- The Gradle scaffold uses the Java 21 toolchain for compilation and tests.
- Java 17 required (retargeted from 21 for Ignition 8.3 compatibility).
- The Gradle scaffold uses the Java 17 toolchain for compilation and tests.
Expected dependencies:
+74 -16
View File
@@ -76,7 +76,40 @@ data-bearing MXAccess failure.
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
cancels the underlying gRPC stream. Canceling or timing out a Java client call
only stops the client from waiting; it does not abort an in-flight MXAccess COM
call on the worker STA.
call on the worker STA. It is a **single-consumer** surface: drive
`hasNext()`/`next()` (or `nextItem()`) from one thread only.
### Reconnect-replay gap signal
When you resume a stream with `streamEventsAfter(afterWorkerSequence)` and the
requested cursor predates the oldest event the gateway still retains, the
gateway emits a single **replay-gap sentinel** at the head of the stream: an
`MxEvent` with its `replay_gap` field set, `family` unspecified, and the body
oneof unset. It means "you missed events — discard cached state and
re-snapshot": the events in the open interval `(requested_after_sequence,
oldest_available_sequence)` were evicted and cannot be replayed. The gateway
never synthesizes this signal from anything else, and the client never swallows
it.
Use `nextItem()` to branch on it as a distinct typed item; `next()` still
returns the sentinel as a plain `MxEvent` (test `event.hasReplayGap()`). After a
gap, re-snapshot, then resume without another gap by requesting
`oldestAvailableSequence - 1` as the next `afterWorkerSequence`:
```java
try (MxEventStream events = session.streamEventsAfter(lastSeenSequence)) {
while (events.hasNext()) {
MxEventStreamItem item = events.nextItem();
if (item.isReplayGap()) {
long resumeFrom = item.replayGap().getOldestAvailableSequence() - 1;
// discard cached per-item state, re-snapshot, then resume from resumeFrom
continue;
}
MxEvent event = item.event();
// normal event handling
}
}
```
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
@@ -89,30 +122,52 @@ ack target). Close the subscription to cancel the underlying gRPC stream.
These are MXAccess parity behaviors that surprise new callers. The gateway
forwards them unchanged — it does not paper over them.
### Typed single-item command helpers
`MxGatewaySession` exposes typed helpers for the parity-critical single-item
commands, so you do not need to build raw `MxCommand` messages:
- `adviseSupervisory(serverHandle, itemHandle)` (and `adviseSupervisoryRaw`)
- `writeSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value)`
and `writeSecured2(..., timestampValue)` (plus `*Raw` variants)
- `authenticateUser(serverHandle, verifyUser, verifyUserPassword)` → user id
- `archestrAUserToId(serverHandle, userIdGuid)` → user id
- `addBufferedItem(serverHandle, itemDefinition, itemContext)` → item handle
- `setBufferedUpdateInterval(serverHandle, updateIntervalMs)`
- `suspend(serverHandle, itemHandle)` / `activate(serverHandle, itemHandle)`
the reply's `MxStatusProxy`
All of them run the same MXAccess reply validation as the bulk helpers (protocol
status plus HRESULT/`MxStatusProxy` check) via the shared `invoke` path, so an
MXAccess COM-side failure surfaces as `MxAccessException`.
**Secret redaction.** Credentials passed to `authenticateUser` (and the
credential-sensitive values passed to `writeSecured`/`writeSecured2`) travel
only in the request. They never appear in logs, exception messages, or
`toString()`: gateway status text is scrubbed through `MxGatewaySecrets`, and
MXAccess failures carry only the reply (never the request). Do not log the
credentials yourself.
### Attributing a write to a user without `authenticateUser`
MXAccess only stamps a plain `write`/`write2` with a Galaxy user id when the
item carries an active *supervisory* advise. If you are **not** using the
verified/secured path (`authenticateUser``writeSecured`/`writeSecured2`) but
still need the write attributed to a user id, you must first advise the item
supervisory and then pass that user id on the write. Without the supervisory
advise the `userId` on a plain write is ignored.
The session exposes `advise`/`unAdvise` but not supervisory advise, so send it
through the generic command channel:
still need the write attributed to a user id, first advise the item supervisory
and then pass that user id on the write. Without the supervisory advise the
`userId` on a plain write is ignored.
```java
session.invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
.setAdviseSupervisory(AdviseSupervisoryCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
session.adviseSupervisory(serverHandle, itemHandle);
session.write(serverHandle, itemHandle, value, userId);
```
The CLI exposes the same command as `advise-supervisory`, and `write` /
**MXAccess parity:** `writeSecured` failing before a prior `authenticateUser` +
`adviseSupervisory`, or before a value-bearing body, is correct behavior — the
native failure is surfaced, not papered over.
The CLI exposes `advise-supervisory`, `write-secured`, and `authenticate-user`
(credential via `--password` or `--password-env`, never echoed), and `write` /
`write2` take `--user-id`.
### Array writes replace the whole array
@@ -324,6 +379,9 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="register --endpoint localhost:5000 -
gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="advise-supervisory --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="authenticate-user --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --verify-user operator --password-env MXGATEWAY_VERIFY_PASSWORD --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="write-secured --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --current-user-id 100 --verifier-user-id 100 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
@@ -351,7 +409,7 @@ Run the Java checks from `clients/java`:
gradle test
```
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC
The build uses the Java 17 Gradle toolchain, compiles generated protobuf/gRPC
code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures,
in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
@@ -155,6 +155,8 @@ public final class MxGatewayCli implements Callable<Integer> {
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
commandLine.addSubcommand(
"advise-supervisory", new AdviseSupervisoryCommand(clientFactory));
commandLine.addSubcommand("write-secured", new WriteSecuredCommand(clientFactory));
commandLine.addSubcommand("authenticate-user", new AuthenticateUserCommand(clientFactory));
commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("read-bulk", new ReadBulkCommand(clientFactory));
@@ -1074,6 +1076,106 @@ public final class MxGatewayCli implements Callable<Integer> {
}
}
@Command(
name = "write-secured",
description = "Invokes MXAccess WriteSecured (verified single-item write).")
static final class WriteSecuredCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
int itemHandle;
@Option(names = "--type", defaultValue = "string", description = "Value type.")
String type;
@Option(names = "--value", required = true, description = "Value text (credential-sensitive; never echoed).")
String value;
@Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
int currentUserId;
@Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
int verifierUserId;
WriteSecuredCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
// The secured write value is credential-sensitive: it goes only
// into the request. The reply printed below never carries it.
MxCommandReply reply = client.session(sessionId)
.writeSecuredRaw(
serverHandle, itemHandle, currentUserId, verifierUserId, parseValue(type, value));
writeOutput("write-secured", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(
name = "authenticate-user",
description = "Invokes MXAccess AuthenticateUser and prints the resolved user id.")
static final class AuthenticateUserCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--verify-user", required = true, description = "Galaxy user name to authenticate.")
String verifyUser;
@Option(
names = "--password",
description = "Galaxy user password (credential; prefer --password-env). Never echoed.")
String password = "";
@Option(
names = "--password-env",
defaultValue = "MXGATEWAY_VERIFY_PASSWORD",
description = "Environment variable holding the password when --password is omitted.")
String passwordEnv;
AuthenticateUserCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
// Resolve the credential from the flag or environment. It flows only
// into the request; it is never written to output, logs, or errors.
String resolvedPassword = password == null || password.isBlank()
? System.getenv(passwordEnv)
: password;
if (resolvedPassword == null) {
resolvedPassword = "";
}
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
int userId = client.session(sessionId)
.authenticateUser(serverHandle, verifyUser, resolvedPassword);
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "authenticate-user");
output.put("options", common.redactedJsonMap());
output.put("verifyUser", verifyUser);
output.put("userId", userId);
out.println(jsonObject(output));
} else {
out.println(userId);
}
}
return 0;
}
}
@Command(name = "subscribe-bulk", description = "Invokes MXAccess SubscribeBulk.")
static final class SubscribeBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
@@ -1864,6 +1966,11 @@ public final class MxGatewayCli implements Callable<Integer> {
MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId);
MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value);
int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword);
List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items);
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
@@ -1982,13 +2089,7 @@ public final class MxGatewayCli implements Callable<Integer> {
@Override
public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
return session.invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
.setAdviseSupervisory(
mxaccess_gateway.v1.MxaccessGateway.AdviseSupervisoryCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
return session.adviseSupervisoryRaw(serverHandle, itemHandle);
}
@Override
@@ -1996,6 +2097,17 @@ public final class MxGatewayCli implements Callable<Integer> {
return session.writeRaw(serverHandle, itemHandle, value, userId);
}
@Override
public MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
return session.writeSecuredRaw(serverHandle, itemHandle, currentUserId, verifierUserId, value);
}
@Override
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
return session.authenticateUser(serverHandle, verifyUser, verifyUserPassword);
}
@Override
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
return session.subscribeBulk(serverHandle, items);
@@ -168,6 +168,49 @@ final class MxGatewayCliTests {
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\""));
}
@Test
void writeSecuredCommandForwardsUserIdsAndValue() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"write-secured",
"--session-id", "session-cli",
"--server-handle", "12",
"--item-handle", "34",
"--type", "int32",
"--value", "77",
"--current-user-id", "100",
"--verifier-user-id", "200",
"--json");
assertEquals(0, run.exitCode());
assertEquals(77, factory.client.session.lastWriteSecuredValue.getInt32Value());
assertEquals(100, factory.client.session.lastWriteSecuredCurrentUserId);
assertEquals(200, factory.client.session.lastWriteSecuredVerifierUserId);
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_WRITE_SECURED\""));
}
@Test
void authenticateUserCommandForwardsCredentialAndPrintsUserIdWithoutEchoingPassword() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"authenticate-user",
"--session-id", "session-cli",
"--server-handle", "3",
"--verify-user", "operator",
"--password", "super-secret-pw",
"--json");
assertEquals(0, run.exitCode());
// The credential reaches the session (request) but never the output.
assertEquals("operator", factory.client.session.lastAuthenticateUser);
assertEquals("super-secret-pw", factory.client.session.lastAuthenticatePassword);
assertTrue(run.output().contains("\"userId\":4242"), run.output());
assertFalse(run.output().contains("super-secret-pw"), "password must never be echoed");
assertFalse(run.errors().contains("super-secret-pw"), "password must never be echoed to stderr");
}
// ---- ping subcommand (D4) ----
@Test
@@ -1257,6 +1300,11 @@ final class MxGatewayCliTests {
private boolean adviseCalled;
private boolean adviseSupervisoryCalled;
private MxValue lastWriteValue;
private MxValue lastWriteSecuredValue;
private int lastWriteSecuredCurrentUserId;
private int lastWriteSecuredVerifierUserId;
private String lastAuthenticateUser;
private String lastAuthenticatePassword;
private String lastPingMessage;
private long lastReadBulkTimeoutMs;
private List<String> lastReadBulkItems;
@@ -1341,6 +1389,25 @@ final class MxGatewayCliTests {
.build();
}
@Override
public MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
lastWriteSecuredValue = value;
lastWriteSecuredCurrentUserId = currentUserId;
lastWriteSecuredVerifierUserId = verifierUserId;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED)
.setProtocolStatus(ok())
.build();
}
@Override
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
lastAuthenticateUser = verifyUser;
lastAuthenticatePassword = verifyUserPassword;
return 4242;
}
@Override
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
List<SubscribeResult> results = new ArrayList<>();
@@ -21,6 +21,24 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
* stream cancels the underlying gRPC call. If the queue overflows the call is
* cancelled and a follow-up call to {@link #next()} throws
* {@link MxGatewayException}.
*
* <p><strong>Single consumer.</strong> This stream is not safe to drain from
* more than one thread. Interleave {@link #hasNext()}/{@link #next()} (or
* {@link #nextItem()}) from a single consumer only; concurrent drains race on
* the internal cursor.
*
* <p><strong>Reconnect-replay gap.</strong> When the stream was resumed via
* {@code StreamEventsRequest.after_worker_sequence} and the requested cursor
* predates the oldest event the gateway still retains, the gateway emits a
* single gap sentinel {@link MxEvent} at the head of the stream with its
* {@code replay_gap} field set (family unspecified, body oneof unset). It is a
* distinct, non-terminal signal, forwarded verbatim never synthesized and
* never swallowed. {@link #next()} returns it as a normal {@link MxEvent}
* (callers can test {@code event.hasReplayGap()}); {@link #nextItem()} wraps it
* in an {@link MxEventStreamItem} whose {@link MxEventStreamItem#isReplayGap()}
* is {@code true}. On a gap the consumer must discard cached per-item state and
* re-snapshot, then resume without a further gap by requesting
* {@code oldest_available_sequence - 1} as the next {@code after_worker_sequence}.
*/
public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
private static final Object END = new Object();
@@ -91,6 +109,24 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
return (MxEvent) value;
}
/**
* Drains the next stream element as a typed {@link MxEventStreamItem} so a
* consumer can branch on the reconnect-replay gap sentinel via
* {@link MxEventStreamItem#isReplayGap()} without inspecting the raw event.
*
* <p>Equivalent to wrapping {@link #next()}; the gap sentinel is surfaced as
* a distinct typed item rather than being swallowed, and normal events are
* returned unchanged on {@link MxEventStreamItem#event()}. Do not mix
* {@link #next()} and {@code nextItem()} on the same element each call
* advances the single shared cursor.
*
* @return the next stream item
* @throws NoSuchElementException if the stream is exhausted
*/
public MxEventStreamItem nextItem() {
return new MxEventStreamItem(next());
}
@Override
public void close() {
closed = true;
@@ -0,0 +1,71 @@
package com.zb.mom.ww.mxgateway.client;
import java.util.Objects;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
/**
* Typed view over a single item drained from an {@link MxEventStream}.
*
* <p>A {@code StreamEvents} stream resumed via {@code after_worker_sequence}
* may begin with a gateway reconnect-replay <em>gap sentinel</em>: a single
* {@link MxEvent} whose {@code replay_gap} field is set, whose
* {@code family} is {@code MX_EVENT_FAMILY_UNSPECIFIED}, and whose {@code body}
* oneof is unset. It means the requested resume cursor predates the oldest
* event the gateway still retains, so the events in the open interval
* {@code (requested_after_sequence, oldest_available_sequence)} were evicted and
* cannot be replayed.
*
* <p>This wrapper lets a consumer branch on that sentinel without inspecting
* the raw event: {@link #isReplayGap()} is {@code true} only for the sentinel,
* and {@link #replayGap()} exposes the typed {@link ReplayGap} cursors. Normal
* MXAccess events return {@code false} from {@link #isReplayGap()} and carry
* their payload on {@link #event()} exactly as before.
*
* <p>The gateway never synthesizes an {@code OperationComplete} or any other
* event from the gap; the sentinel is the gateway's own forwarded signal and is
* surfaced here untouched. On receiving a gap the consumer must discard any
* cached per-item state and re-snapshot, then resume without incurring another
* gap by requesting {@code oldest_available_sequence - 1} as the new
* {@code after_worker_sequence} cursor.
*
* @param event the raw event; for a gap sentinel this is the sentinel event
* itself (family unspecified, body unset, {@code replay_gap} set)
*/
public record MxEventStreamItem(MxEvent event) {
/**
* Creates a stream-item view over the supplied event.
*
* @param event the raw event; must not be {@code null}
*/
public MxEventStreamItem {
Objects.requireNonNull(event, "event");
}
/**
* Returns whether this item is the reconnect-replay gap sentinel rather
* than a normal MXAccess event. Detected via the generated
* {@code MxEvent.hasReplayGap()} presence flag.
*
* @return {@code true} for the gap sentinel, {@code false} for normal events
*/
public boolean isReplayGap() {
return event.hasReplayGap();
}
/**
* Returns the typed reconnect-replay gap descriptor when this item is the
* gap sentinel.
*
* @return the {@link ReplayGap} carrying {@code requested_after_sequence}
* and {@code oldest_available_sequence}
* @throws IllegalStateException if this item is a normal event (check
* {@link #isReplayGap()} first)
*/
public ReplayGap replayGap() {
if (!event.hasReplayGap()) {
throw new IllegalStateException("stream item is not a replay-gap sentinel");
}
return event.getReplayGap();
}
}
@@ -7,11 +7,16 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import mxaccess_gateway.v1.MxaccessGateway.ActivateCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddBufferedItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddItem2Command;
import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseSupervisoryCommand;
import mxaccess_gateway.v1.MxaccessGateway.ArchestrAUserToIdCommand;
import mxaccess_gateway.v1.MxaccessGateway.AuthenticateUserCommand;
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
@@ -23,15 +28,18 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
import mxaccess_gateway.v1.MxaccessGateway.MxSparseArray;
import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.SetBufferedUpdateIntervalCommand;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import mxaccess_gateway.v1.MxaccessGateway.SuspendCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand;
@@ -42,6 +50,8 @@ import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2Command;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkCommand;
@@ -697,6 +707,285 @@ public final class MxGatewaySession implements AutoCloseable {
.build());
}
/**
* Invokes MXAccess {@code AdviseSupervisory} so the item accepts
* supervisory-attributed writes. Required before a plain {@link #write}
* can stamp a Galaxy user id without the verified/secured path.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to advise supervisory
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public void adviseSupervisory(int serverHandle, int itemHandle) {
adviseSupervisoryRaw(serverHandle, itemHandle);
}
/**
* Invokes MXAccess {@code AdviseSupervisory} and returns the raw reply.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to advise supervisory
* @return the raw command reply
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
.setAdviseSupervisory(AdviseSupervisoryCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
}
/**
* Invokes MXAccess {@code WriteSecured} a verified write gated by a
* previously authenticated Galaxy user.
*
* <p><strong>MXAccess parity:</strong> the native call fails if the caller
* has not first {@link #authenticateUser authenticated} and
* {@link #adviseSupervisory advised supervisory}, or if the value body is
* absent; that native failure is surfaced (as {@link MxAccessException}),
* not papered over.
*
* <p><strong>Secret handling:</strong> {@code value} may carry a
* credential-sensitive payload. It is placed only in the request and never
* appears in any surfaced error (exceptions carry the reply, not the
* request), so callers must likewise avoid logging it.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to write
* @param currentUserId the authenticated (current) Galaxy user id
* @param verifierUserId the verifier Galaxy user id (second-signature); use
* the same value as {@code currentUserId} when no separate verifier applies
* @param value the credential-sensitive value to write
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public void writeSecured(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
writeSecuredRaw(serverHandle, itemHandle, currentUserId, verifierUserId, value);
}
/**
* Invokes MXAccess {@code WriteSecured} and returns the raw reply.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to write
* @param currentUserId the authenticated (current) Galaxy user id
* @param verifierUserId the verifier Galaxy user id
* @param value the credential-sensitive value to write
* @return the raw command reply
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED)
.setWriteSecured(WriteSecuredCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle)
.setCurrentUserId(currentUserId)
.setVerifierUserId(verifierUserId)
.setValue(value))
.build());
}
/**
* Invokes MXAccess {@code WriteSecured2} a verified, explicitly
* timestamped write. Parity and secret-handling notes mirror
* {@link #writeSecured}.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to write
* @param currentUserId the authenticated (current) Galaxy user id
* @param verifierUserId the verifier Galaxy user id
* @param value the credential-sensitive value to write
* @param timestampValue the timestamp value to associate with the write
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public void writeSecured2(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
MxValue value,
MxValue timestampValue) {
writeSecured2Raw(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestampValue);
}
/**
* Invokes MXAccess {@code WriteSecured2} and returns the raw reply.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to write
* @param currentUserId the authenticated (current) Galaxy user id
* @param verifierUserId the verifier Galaxy user id
* @param value the credential-sensitive value to write
* @param timestampValue the timestamp value to associate with the write
* @return the raw command reply
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public MxCommandReply writeSecured2Raw(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
MxValue value,
MxValue timestampValue) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED2)
.setWriteSecured2(WriteSecured2Command.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle)
.setCurrentUserId(currentUserId)
.setVerifierUserId(verifierUserId)
.setValue(value)
.setTimestampValue(timestampValue))
.build());
}
/**
* Invokes MXAccess {@code AuthenticateUser} and returns the resolved
* Galaxy user id used to attribute subsequent secured writes.
*
* <p><strong>Secret handling:</strong> {@code verifyUserPassword} is a raw
* MXAccess credential. It is placed only in the request and never appears in
* any surfaced error, log, or {@code toString()} (exceptions carry the
* reply, not the request). Callers must not log it either.
*
* @param serverHandle the {@code ServerHandle} for the session
* @param verifyUser the Galaxy user name to authenticate
* @param verifyUserPassword the user's credential; never logged or surfaced
* @return the authenticated Galaxy user id
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess rejects the credential
*/
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_AUTHENTICATE_USER)
.setAuthenticateUser(AuthenticateUserCommand.newBuilder()
.setServerHandle(serverHandle)
.setVerifyUser(verifyUser)
.setVerifyUserPassword(verifyUserPassword))
.build());
if (reply.hasAuthenticateUser()) {
return reply.getAuthenticateUser().getUserId();
}
return reply.getReturnValue().getInt32Value();
}
/**
* Invokes MXAccess {@code ArchestrAUserToId}, resolving a Galaxy user GUID
* to its integer user id.
*
* @param serverHandle the {@code ServerHandle} for the session
* @param userIdGuid the Galaxy user GUID string
* @return the resolved Galaxy user id
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public int archestrAUserToId(int serverHandle, String userIdGuid) {
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID)
.setArchestraUserToId(ArchestrAUserToIdCommand.newBuilder()
.setServerHandle(serverHandle)
.setUserIdGuid(userIdGuid))
.build());
if (reply.hasArchestraUserToId()) {
return reply.getArchestraUserToId().getUserId();
}
return reply.getReturnValue().getInt32Value();
}
/**
* Invokes MXAccess {@code AddBufferedItem} and returns the new item handle.
* The buffered add family delivers coalesced {@code OnBufferedDataChange}
* updates on the interval set by {@link #setBufferedUpdateInterval}.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemDefinition the MXAccess item definition (tag reference)
* @param itemContext the MXAccess item context (e.g. galaxy/object scope)
* @return the {@code ItemHandle} assigned by MXAccess
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public int addBufferedItem(int serverHandle, String itemDefinition, String itemContext) {
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_BUFFERED_ITEM)
.setAddBufferedItem(AddBufferedItemCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemDefinition(itemDefinition)
.setItemContext(itemContext))
.build());
if (reply.hasAddBufferedItem()) {
return reply.getAddBufferedItem().getItemHandle();
}
return reply.getReturnValue().getInt32Value();
}
/**
* Invokes MXAccess {@code SetBufferedUpdateInterval}, controlling how often
* the worker coalesces buffered updates for the given server handle.
*
* @param serverHandle the {@code ServerHandle} to configure
* @param updateIntervalMilliseconds the buffered update interval in milliseconds
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public void setBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) {
invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL)
.setSetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand.newBuilder()
.setServerHandle(serverHandle)
.setUpdateIntervalMilliseconds(updateIntervalMilliseconds))
.build());
}
/**
* Invokes MXAccess {@code Suspend} on an item and returns the reply's
* {@link MxStatusProxy}.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to suspend
* @return the {@code MxStatusProxy} carried by the suspend reply
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public MxStatusProxy suspend(int serverHandle, int itemHandle) {
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_SUSPEND)
.setSuspend(SuspendCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
return reply.getSuspend().getStatus();
}
/**
* Invokes MXAccess {@code Activate} on a previously suspended item and
* returns the reply's {@link MxStatusProxy}.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to activate
* @return the {@code MxStatusProxy} carried by the activate reply
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public MxStatusProxy activate(int serverHandle, int itemHandle) {
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ACTIVATE)
.setActivate(ActivateCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
return reply.getActivate().getStatus();
}
/**
* Subscribes to gateway events for this session starting from the
* beginning of the worker event log.
@@ -1,6 +1,7 @@
package com.zb.mom.ww.mxgateway.client;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -30,6 +31,7 @@ import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
import mxaccess_gateway.v1.MxaccessGateway.AuthenticateUserReply;
import mxaccess_gateway.v1.MxaccessGateway.BulkSubscribeReply;
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
@@ -39,6 +41,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.MxEventFamily;
import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
@@ -47,6 +50,7 @@ import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
@@ -509,6 +513,232 @@ final class MxGatewayClientSessionTests {
}
}
@Test
void streamEventsSurfacesReplayGapSentinelAsTypedItem() throws Exception {
// CLI-15: a resumed stream whose cursor predates the retained window
// begins with the gateway's replay-gap sentinel. It must surface as a
// distinct typed item, and normal events must be unaffected.
TestGatewayService service = new TestGatewayService() {
@Override
public void streamEvents(StreamEventsRequest request, StreamObserver<MxEvent> responseObserver) {
responseObserver.onNext(MxEvent.newBuilder()
.setSessionId(request.getSessionId())
.setReplayGap(ReplayGap.newBuilder()
.setRequestedAfterSequence(5)
.setOldestAvailableSequence(9))
.build());
responseObserver.onNext(MxEvent.newBuilder()
.setSessionId(request.getSessionId())
.setFamily(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE)
.setWorkerSequence(9)
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5));
MxEventStream events =
MxGatewaySession.forSessionId(client, "resume-session").streamEventsAfter(5)) {
assertTrue(events.hasNext());
MxEventStreamItem gap = events.nextItem();
assertTrue(gap.isReplayGap(), "head sentinel must classify as a replay gap");
assertEquals(5, gap.replayGap().getRequestedAfterSequence());
assertEquals(9, gap.replayGap().getOldestAvailableSequence());
// Sentinel carries no MXAccess payload.
assertEquals(MxEventFamily.MX_EVENT_FAMILY_UNSPECIFIED, gap.event().getFamily());
assertTrue(events.hasNext());
MxEventStreamItem normal = events.nextItem();
assertFalse(normal.isReplayGap(), "normal event must not classify as a replay gap");
assertEquals(9, normal.event().getWorkerSequence());
assertEquals(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE, normal.event().getFamily());
}
}
@Test
void adviseSupervisoryBuildsSupervisoryCommand() throws Exception {
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
TestGatewayService service = okInvokeService(commandRequest);
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "advise-super-session");
session.adviseSupervisory(12, 34);
assertEquals(
MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
commandRequest.get().getCommand().getKind());
assertEquals(12, commandRequest.get().getCommand().getAdviseSupervisory().getServerHandle());
assertEquals(34, commandRequest.get().getCommand().getAdviseSupervisory().getItemHandle());
}
}
@Test
void writeSecuredSurfacesNativeMxAccessFailure() throws Exception {
// CLI-04 parity: WriteSecured before authenticate/advise fails natively;
// the client surfaces the failure rather than papering over it.
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
.setMessage("WriteSecured rejected: user not authenticated."))
.setHresult(-2147220992)
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "secured-session");
MxAccessException error = assertThrows(
MxAccessException.class,
() -> session.writeSecured(1, 2, 100, 100, MxValues.stringValue("secret-payload")));
assertEquals(-2147220992, error.reply().getHresult());
// The credential-bearing value lives only in the request, so it must
// not appear in any surfaced error text.
assertFalse(String.valueOf(error.getMessage()).contains("secret-payload"));
}
}
@Test
void writeSecuredScriptedSuccessSendsSecuredCommand() throws Exception {
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
TestGatewayService service = okInvokeService(commandRequest);
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "secured-ok-session");
session.writeSecured(7, 8, 100, 200, MxValues.int32Value(42));
var command = commandRequest.get().getCommand();
assertEquals(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED, command.getKind());
assertEquals(7, command.getWriteSecured().getServerHandle());
assertEquals(8, command.getWriteSecured().getItemHandle());
assertEquals(100, command.getWriteSecured().getCurrentUserId());
assertEquals(200, command.getWriteSecured().getVerifierUserId());
assertEquals(42, command.getWriteSecured().getValue().getInt32Value());
}
}
@Test
void authenticateUserReturnsUserIdAndForwardsCredential() throws Exception {
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
commandRequest.set(request);
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok())
.setAuthenticateUser(AuthenticateUserReply.newBuilder().setUserId(4242))
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "auth-session");
int userId = session.authenticateUser(3, "operator", "super-secret-pw");
assertEquals(4242, userId);
// The credential is forwarded in the request only.
assertEquals(
"super-secret-pw",
commandRequest.get().getCommand().getAuthenticateUser().getVerifyUserPassword());
}
}
@Test
void authenticateUserFailureKeepsCredentialOutOfSurfacedError() throws Exception {
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
.setMessage("AuthenticateUser rejected the credential."))
.setHresult(-2147220992)
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "auth-fail-session");
MxAccessException error = assertThrows(
MxAccessException.class,
() -> session.authenticateUser(3, "operator", "super-secret-pw"));
// The password must never reach the exception message or toString().
assertFalse(String.valueOf(error.getMessage()).contains("super-secret-pw"));
assertFalse(error.toString().contains("super-secret-pw"));
}
}
@Test
void suspendAndActivateReturnStatusProxy() throws Exception {
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
MxCommandReply.Builder reply = MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok());
if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_SUSPEND) {
reply.setSuspend(mxaccess_gateway.v1.MxaccessGateway.SuspendReply.newBuilder()
.setStatus(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.newBuilder()
.setSuccess(1)));
} else if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_ACTIVATE) {
reply.setActivate(mxaccess_gateway.v1.MxaccessGateway.ActivateReply.newBuilder()
.setStatus(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.newBuilder()
.setSuccess(1)));
}
responseObserver.onNext(reply.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "suspend-session");
assertTrue(MxStatuses.succeeded(session.suspend(1, 2)));
assertTrue(MxStatuses.succeeded(session.activate(1, 2)));
}
}
private static TestGatewayService okInvokeService(AtomicReference<MxCommandRequest> commandRequest) {
return new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
commandRequest.set(request);
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
};
}
private static ProtocolStatus ok() {
return ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
+61 -11
View File
@@ -105,6 +105,40 @@ terminate the stream.
Canceling a Python task cancels the client-side gRPC call or stream wait. It
does not abort an in-flight MXAccess COM call inside the worker process.
### Event streaming and reconnect gaps
`Session.stream_events()` yields an async iterator whose items are either a
normal `MxEvent` or a `ReplayGap`. Track the `worker_sequence` of the last event
you processed and pass it back as `after_worker_sequence` to resume after a
disconnect:
```python
from zb_mom_ww_mxgateway import ReplayGap
cursor = 0
async for item in session.stream_events(after_worker_sequence=cursor):
if isinstance(item, ReplayGap):
# The gateway dropped events between item.requested_after_sequence and
# item.oldest_available_sequence — they are gone from the replay ring.
# Discard local tag/alarm state and re-snapshot (e.g. read_bulk /
# query_active_alarms), then resume without another gap:
cursor = item.resume_after_worker_sequence # oldest_available_sequence - 1
continue
# Normal MXAccess event.
cursor = item.worker_sequence
handle(item)
```
`ReplayGap` is the gateway's reconnect-replay gap sentinel made typed and
observable. It is delivered only at the head of a stream resumed with a non-zero
`after_worker_sequence` when the requested cursor predates the oldest retained
event. It is a **non-terminal** signal — the stream continues with normal events
after it — and it is never yielded as an `MxEvent`, so it can never be mistaken
for a real MXAccess event. The client does not synthesize or swallow it; the
gateway only sets it on `StreamEvents` results (never on a fresh stream or a
`DrainEvents` reply). `GatewayClient.stream_events_raw` remains the raw protobuf
stream (the sentinel arrives there as an `MxEvent` with `replay_gap` set).
## Write Semantics And Common Pitfalls
These are MXAccess parity behaviors that surprise new callers. The gateway
@@ -119,19 +153,11 @@ but still need the write attributed to a user id, you must first advise the
item supervisory and then pass that user id on the write. Without the
supervisory advise the `user_id` on a plain write is ignored.
The session exposes `advise`/`unadvise` but not supervisory advise, so send it
through the generic command channel:
The session exposes a typed `advise_supervisory` helper alongside
`advise`/`unadvise`:
```python
await session.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
advise_supervisory=pb.AdviseSupervisoryCommand(
server_handle=server_handle,
item_handle=item_handle,
),
)
)
await session.advise_supervisory(server_handle, item_handle)
await session.write(server_handle, item_handle, value, user_id=user_id)
```
@@ -139,6 +165,30 @@ await session.write(server_handle, item_handle, value, user_id=user_id)
The CLI exposes the same command as `advise-supervisory`, and `write` /
`write2` take `--user-id`.
For the verified/secured path, `authenticate_user`, `write_secured`,
`write_secured2`, and `archestra_user_to_id` are typed session helpers too. The
credential passed to `authenticate_user` and the values written by
`write_secured`/`write_secured2` are treated as secrets: they are never logged
and are scrubbed from any surfaced error message. MXAccess parity is preserved —
a `write_secured` that fails because no prior `authenticate_user` +
`advise_supervisory` established a supervisory context surfaces the native
failure as `MxAccessError` rather than being silently "fixed":
```python
user_id = await session.authenticate_user(server_handle, "operator", password)
await session.advise_supervisory(server_handle, item_handle)
await session.write_secured(
server_handle,
item_handle,
value,
current_user_id=user_id,
verifier_user_id=user_id,
)
```
The CLI mirrors these as `authenticate-user` (credential via `--password` or,
preferably, `--password-env`) and `write-secured`.
### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an
@@ -9,6 +9,7 @@ from .generated.galaxy_repository_pb2 import (
GalaxyObject,
WatchDeployEventsRequest,
)
from .events import ReplayGap
from .errors import (
MxAccessError,
MxGatewayAuthenticationError,
@@ -43,6 +44,7 @@ __all__ = [
"MxGatewayTransportError",
"MxGatewayWorkerError",
"MxValueView",
"ReplayGap",
"Session",
"WatchDeployEventsRequest",
"__version__",
@@ -0,0 +1,63 @@
"""Typed event-stream signals for the MXAccess Gateway Python client."""
from __future__ import annotations
from dataclasses import dataclass
from .generated import mxaccess_gateway_pb2 as pb
@dataclass(frozen=True, slots=True)
class ReplayGap:
"""Reconnect-replay gap signal surfaced on a resumed event stream.
The gateway emits this at the head of a stream resumed with
:meth:`Session.stream_events`'s ``after_worker_sequence`` cursor when the
requested sequence predates the oldest event still retained in the gateway's
replay ring. That means the events between ``requested_after_sequence`` and
``oldest_available_sequence`` were dropped from the ring and can no longer be
replayed the client has an unrecoverable hole in its event history.
``ReplayGap`` is a *non-terminal, observable* signal: the stream keeps
delivering normal :class:`~zb_mom_ww_mxgateway.generated.mxaccess_gateway_pb2.MxEvent`
values after it. :meth:`Session.stream_events` yields it as a distinct type
(never as an ``MxEvent``) so a consumer can branch on
``isinstance(item, ReplayGap)`` and never mistake a gap for a real MXAccess
event. The client neither synthesizes nor swallows the gateway's sentinel —
it only makes that sentinel typed and observable.
On seeing a gap the consumer must discard any locally cached tag/alarm state
and re-snapshot (for example via :meth:`Session.read_bulk` or
:meth:`~zb_mom_ww_mxgateway.GatewayClient.query_active_alarms`). To resume
the stream without provoking another gap, reconnect with
``after_worker_sequence = gap.resume_after_worker_sequence`` (that is,
``oldest_available_sequence - 1``) so the next replayed event is the oldest
the gateway still retains.
The gateway sets this only on ``StreamEvents`` results never on a normal
(non-resumed) stream and never on a ``DrainEvents`` reply.
"""
requested_after_sequence: int
"""The ``after_worker_sequence`` cursor the resumed stream was opened with."""
oldest_available_sequence: int
"""Oldest worker sequence the gateway can still replay."""
@classmethod
def from_proto(cls, gap: pb.ReplayGap) -> "ReplayGap":
"""Build a :class:`ReplayGap` from the generated ``ReplayGap`` message."""
return cls(
requested_after_sequence=gap.requested_after_sequence,
oldest_available_sequence=gap.oldest_available_sequence,
)
@property
def resume_after_worker_sequence(self) -> int:
"""``after_worker_sequence`` to resume the stream without another gap.
Equal to ``oldest_available_sequence - 1`` so the next event the gateway
replays is ``oldest_available_sequence`` the oldest it still retains.
Clamped at ``0`` so it is never negative.
"""
return max(self.oldest_available_sequence - 1, 0)
@@ -4,7 +4,9 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
from .errors import ensure_mxaccess_success
from .auth import redact_secret
from .errors import MxGatewayError, ensure_mxaccess_success
from .events import ReplayGap
from .generated import mxaccess_gateway_pb2 as pb
from .values import MxValueInput, to_mx_value
@@ -568,18 +570,304 @@ class Session:
correlation_id=correlation_id,
)
async def _invoke_redacted(
self,
command: pb.MxCommand,
*,
correlation_id: str,
secrets: Sequence[str | None],
) -> pb.MxCommandReply:
"""Invoke a command whose request carries credential-sensitive data.
Runs the same gateway + MXAccess validation as :meth:`invoke`, but scrubs
the supplied secret substrings from any surfaced error message before it
propagates. MXAccess parity is preserved the native failure is still
raised as :class:`~zb_mom_ww_mxgateway.errors.MxAccessError`; only the
credential text is removed from the message so it can never reach logs.
"""
try:
return await self.invoke(command, correlation_id=correlation_id)
except MxGatewayError as error:
_redact_error(error, secrets)
raise
async def advise_supervisory(
self,
server_handle: int,
item_handle: int,
*,
correlation_id: str = "",
) -> None:
"""Invoke MXAccess `AdviseSupervisory` for an `ItemHandle`.
Supervisory advise is the prerequisite for user-attributed and secured
writes: it must be established (typically after :meth:`authenticate_user`)
before a ``user_id``-bearing :meth:`write` or a :meth:`write_secured`
takes effect.
"""
await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
advise_supervisory=pb.AdviseSupervisoryCommand(
server_handle=server_handle,
item_handle=item_handle,
),
),
correlation_id=correlation_id,
)
async def write_secured(
self,
server_handle: int,
item_handle: int,
value: MxValueInput,
*,
current_user_id: int = 0,
verifier_user_id: int = 0,
correlation_id: str = "",
) -> None:
"""Invoke MXAccess `WriteSecured` — a signed/verified write.
The written *value* is credential-sensitive and is scrubbed from any
surfaced error message (never logged). MXAccess parity is the contract:
``WriteSecured`` failing before a prior :meth:`authenticate_user` +
:meth:`advise_supervisory`, or before a value-bearing body, is the native
behaviour and is surfaced as-is it is not "fixed".
"""
await self._invoke_redacted(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_WRITE_SECURED,
write_secured=pb.WriteSecuredCommand(
server_handle=server_handle,
item_handle=item_handle,
current_user_id=current_user_id,
verifier_user_id=verifier_user_id,
value=to_mx_value(value),
),
),
correlation_id=correlation_id,
secrets=_value_secrets(value),
)
async def write_secured2(
self,
server_handle: int,
item_handle: int,
value: MxValueInput,
timestamp_value: MxValueInput,
*,
current_user_id: int = 0,
verifier_user_id: int = 0,
correlation_id: str = "",
) -> None:
"""Invoke MXAccess `WriteSecured2` — a signed/verified, timestamped write.
Like :meth:`write_secured` but also stamps a client-supplied timestamp.
The written *value* is credential-sensitive and is scrubbed from any
surfaced error message. Native pre-condition failures are surfaced as-is.
"""
await self._invoke_redacted(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2,
write_secured2=pb.WriteSecured2Command(
server_handle=server_handle,
item_handle=item_handle,
current_user_id=current_user_id,
verifier_user_id=verifier_user_id,
value=to_mx_value(value),
timestamp_value=to_mx_value(timestamp_value),
),
),
correlation_id=correlation_id,
secrets=_value_secrets(value),
)
async def authenticate_user(
self,
server_handle: int,
verify_user: str,
verify_user_password: str,
*,
correlation_id: str = "",
) -> int:
"""Invoke MXAccess `AuthenticateUser` and return the resolved Galaxy user id.
*verify_user_password* is a raw MXAccess credential: it is never logged
and is scrubbed from any surfaced error message. A native authentication
failure is surfaced as :class:`~zb_mom_ww_mxgateway.errors.MxAccessError`
with the credential removed.
"""
reply = await self._invoke_redacted(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
authenticate_user=pb.AuthenticateUserCommand(
server_handle=server_handle,
verify_user=verify_user,
verify_user_password=verify_user_password,
),
),
correlation_id=correlation_id,
secrets=[verify_user_password],
)
return reply.authenticate_user.user_id
async def archestra_user_to_id(
self,
server_handle: int,
user_id_guid: str,
*,
correlation_id: str = "",
) -> int:
"""Invoke MXAccess `ArchestrAUserToId` and return the resolved user id."""
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID,
archestra_user_to_id=pb.ArchestrAUserToIdCommand(
server_handle=server_handle,
user_id_guid=user_id_guid,
),
),
correlation_id=correlation_id,
)
return reply.archestra_user_to_id.user_id
async def add_buffered_item(
self,
server_handle: int,
item_definition: str,
item_context: str = "",
*,
correlation_id: str = "",
) -> int:
"""Invoke MXAccess `AddBufferedItem` and return the new `ItemHandle`."""
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_ADD_BUFFERED_ITEM,
add_buffered_item=pb.AddBufferedItemCommand(
server_handle=server_handle,
item_definition=item_definition,
item_context=item_context,
),
),
correlation_id=correlation_id,
)
return reply.add_buffered_item.item_handle
async def set_buffered_update_interval(
self,
server_handle: int,
update_interval_milliseconds: int,
*,
correlation_id: str = "",
) -> None:
"""Invoke MXAccess `SetBufferedUpdateInterval` for the server handle."""
await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL,
set_buffered_update_interval=pb.SetBufferedUpdateIntervalCommand(
server_handle=server_handle,
update_interval_milliseconds=update_interval_milliseconds,
),
),
correlation_id=correlation_id,
)
async def suspend(
self,
server_handle: int,
item_handle: int,
*,
correlation_id: str = "",
) -> pb.MxStatusProxy:
"""Invoke MXAccess `Suspend` for an `ItemHandle` and return its status."""
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_SUSPEND,
suspend=pb.SuspendCommand(
server_handle=server_handle,
item_handle=item_handle,
),
),
correlation_id=correlation_id,
)
return reply.suspend.status
async def activate(
self,
server_handle: int,
item_handle: int,
*,
correlation_id: str = "",
) -> pb.MxStatusProxy:
"""Invoke MXAccess `Activate` for a suspended `ItemHandle` and return its status."""
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_ACTIVATE,
activate=pb.ActivateCommand(
server_handle=server_handle,
item_handle=item_handle,
),
),
correlation_id=correlation_id,
)
return reply.activate.status
def stream_events(
self,
*,
after_worker_sequence: int = 0,
) -> AsyncIterator[pb.MxEvent]:
"""Return an async iterator of `MxEvent` messages for this session."""
return self.client.stream_events_raw(
) -> AsyncIterator[pb.MxEvent | ReplayGap]:
"""Return an async iterator over this session's `MxEvent` stream.
Each yielded item is either a normal :class:`~...MxEvent` or a
:class:`ReplayGap`. Branch on ``isinstance(item, ReplayGap)`` a gap is
never delivered as an ``MxEvent`` so it cannot be mistaken for a real
MXAccess event.
Pass a non-zero *after_worker_sequence* to resume a previously observed
stream. If that cursor predates the oldest event the gateway still
retains in its replay ring, the stream opens with a single
:class:`ReplayGap` sentinel (events in the gap were dropped and cannot be
replayed), then continues with normal events. On a gap, discard locally
cached state, re-snapshot, and to resume without another gap
reconnect with ``after_worker_sequence = gap.resume_after_worker_sequence``.
See :class:`ReplayGap` for the full semantics. The underlying protobuf
stream is available raw via ``GatewayClient.stream_events_raw``.
"""
raw = self.client.stream_events_raw(
pb.StreamEventsRequest(
session_id=self.session_id,
after_worker_sequence=after_worker_sequence,
),
)
return _surface_replay_gaps(raw)
async def _surface_replay_gaps(
raw: AsyncIterator[pb.MxEvent],
) -> AsyncIterator[pb.MxEvent | ReplayGap]:
"""Map the gateway's ``replay_gap`` sentinel event to a typed :class:`ReplayGap`.
Normal events pass through unchanged. The sentinel (``replay_gap`` set,
``family`` unspecified, body unset) is converted to a distinct
:class:`ReplayGap` so a consumer can branch on it without inspecting proto
presence, and is never yielded as an ``MxEvent``. The sentinel is forwarded
faithfully it is neither dropped nor turned into a normal event.
Closing this generator (``aclose``) propagates to *raw* so the underlying
gRPC call is cancelled, preserving the raw stream's cancel-on-stop contract.
"""
try:
async for event in raw:
if event.HasField("replay_gap"):
yield ReplayGap.from_proto(event.replay_gap)
else:
yield event
finally:
aclose = getattr(raw, "aclose", None)
if aclose is not None:
await aclose()
def _ensure_bulk_size(name: str, count: int) -> None:
@@ -587,4 +875,39 @@ def _ensure_bulk_size(name: str, count: int) -> None:
raise ValueError(f"{name} bulk commands are limited to {MAX_BULK_ITEMS} item(s)")
def _value_secrets(value: MxValueInput) -> list[str]:
"""Return the redaction candidate strings for a credential-sensitive write value.
Secured-write payloads may carry a password or other secret. Only textual
values can appear verbatim in a surfaced error, so a string value (or a
UTF-8-decodable ``bytes`` value) is returned for scrubbing; other value
kinds have no verbatim text form to leak.
"""
if isinstance(value, str):
return [value] if value else []
if isinstance(value, bytes):
try:
decoded = value.decode("utf-8")
except UnicodeDecodeError:
return []
return [decoded] if decoded else []
return []
def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
"""Scrub secret substrings from a raised error's message in place.
Rewrites ``error.args[0]`` (the message returned by ``str(error)``) through
the shared :func:`~zb_mom_ww_mxgateway.auth.redact_secret` seam so credential
text can never reach logs or be re-raised to a caller. The
``protocol_status`` / ``raw_reply`` context is left untouched those hold the
gateway's own fields, which never echo the client-supplied secret.
"""
scrubbed = [secret for secret in secrets if secret]
if not scrubbed:
return
if error.args and isinstance(error.args[0], str):
error.args = (redact_secret(error.args[0], scrubbed), *error.args[1:])
from .client import GatewayClient # noqa: E402
@@ -1,3 +1,3 @@
"""Package version information."""
__version__ = "0.1.0"
__version__ = "0.1.2"
@@ -294,6 +294,56 @@ def advise_supervisory(**kwargs: Any) -> None:
)
@main.command("write-secured")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
@click.option("--type", "value_type", default="string", show_default=True)
@click.option("--value", required=True, help="Value to write (credential-sensitive; never logged).")
@click.option("--current-user-id", default=0, type=int, show_default=True)
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def write_secured(**kwargs: Any) -> None:
"""Invoke MXAccess WriteSecured — a signed/verified write (credential-sensitive)."""
_run(
_write_secured(**kwargs),
output_json=kwargs["output_json"],
secrets=_secrets(kwargs) + [kwargs.get("value")],
)
@main.command("authenticate-user")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--verify-user", required=True, help="MXAccess user name to authenticate.")
@click.option(
"--password",
default=None,
help="User password. Prefer --password-env so the secret is not visible on the command line.",
)
@click.option(
"--password-env",
default=None,
help="Environment variable holding the user password.",
)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def authenticate_user(**kwargs: Any) -> None:
"""Invoke MXAccess AuthenticateUser — resolve a Galaxy user id (credential-sensitive)."""
password = _resolve_password(kwargs)
kwargs["password"] = password
_run(
_authenticate_user(**kwargs),
output_json=kwargs["output_json"],
secrets=_secrets(kwargs) + [password],
)
@main.command("subscribe-bulk")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@@ -745,19 +795,58 @@ async def _advise(**kwargs: Any) -> dict[str, Any]:
async def _advise_supervisory(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client:
session = _session(client, kwargs["session_id"])
await session.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
advise_supervisory=pb.AdviseSupervisoryCommand(
server_handle=kwargs["server_handle"],
item_handle=kwargs["item_handle"],
),
),
await session.advise_supervisory(
kwargs["server_handle"],
kwargs["item_handle"],
correlation_id=kwargs["correlation_id"],
)
return {"ok": True}
async def _write_secured(**kwargs: Any) -> dict[str, Any]:
value = _parse_value(kwargs["value"], kwargs["value_type"])
async with await _connect(kwargs) as client:
session = _session(client, kwargs["session_id"])
await session.write_secured(
kwargs["server_handle"],
kwargs["item_handle"],
value,
current_user_id=kwargs["current_user_id"],
verifier_user_id=kwargs["verifier_user_id"],
correlation_id=kwargs["correlation_id"],
)
return {"ok": True}
async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client:
session = _session(client, kwargs["session_id"])
user_id = await session.authenticate_user(
kwargs["server_handle"],
kwargs["verify_user"],
kwargs["password"],
correlation_id=kwargs["correlation_id"],
)
return {"userId": user_id}
def _resolve_password(kwargs: dict[str, Any]) -> str:
"""Resolve the authenticate-user password from --password or --password-env.
Prefers the explicit flag, then falls back to the named environment
variable. The resolved secret is never echoed; callers pass it into the
``secrets`` redaction list so it cannot leak through a surfaced error.
"""
password = kwargs.get("password")
if not password:
env_name = kwargs.get("password_env")
password = os.environ.get(env_name) if env_name else None
if not password:
raise click.UsageError("a password is required via --password or --password-env")
return password
async def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client:
session = _session(client, kwargs["session_id"])
+172
View File
@@ -645,3 +645,175 @@ def test_galaxy_browse_help_shows_parent_gobject_id() -> None:
assert result.exit_code == 0
assert "--parent-gobject-id" in result.output
class _FakeInvokeClient:
"""Async-context-manager fake whose invoke_raw returns a scripted reply.
Satisfies the session-backed CLI command bodies (register / write-secured /
authenticate-user) which build a Session over this client and call through to
``invoke_raw``. Records the last command so tests can assert credentials are
carried on the wire but never echoed to stdout.
"""
def __init__(self, reply) -> None:
self._reply = reply
self.last_request = None
async def __aenter__(self) -> "_FakeInvokeClient":
return self
async def __aexit__(self, *_exc: object) -> None:
return None
async def invoke_raw(self, request):
self.last_request = request
return self._reply
def test_authenticate_user_command_returns_user_id_without_echoing_password(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
reply = pb.MxCommandReply(
session_id="s1",
kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
authenticate_user=pb.AuthenticateUserReply(user_id=42),
)
fake = _FakeInvokeClient(reply)
async def fake_connect(options, **_kwargs):
return fake
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
result = CliRunner().invoke(
main,
[
"authenticate-user",
"--plaintext",
"--session-id",
"s1",
"--server-handle",
"3",
"--verify-user",
"operator",
"--password",
"cli-secret-pw",
"--json",
],
)
assert result.exit_code == 0, result.output
assert json.loads(result.output)["userId"] == 42
assert "cli-secret-pw" not in result.output
# Credential is carried on the wire, not echoed.
assert fake.last_request.command.authenticate_user.verify_user_password == "cli-secret-pw"
def test_authenticate_user_reads_password_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
reply = pb.MxCommandReply(
session_id="s1",
kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
authenticate_user=pb.AuthenticateUserReply(user_id=7),
)
fake = _FakeInvokeClient(reply)
async def fake_connect(options, **_kwargs):
return fake
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
monkeypatch.setenv("MXGW_TEST_PW", "env-secret-pw")
result = CliRunner().invoke(
main,
[
"authenticate-user",
"--plaintext",
"--session-id",
"s1",
"--server-handle",
"3",
"--verify-user",
"operator",
"--password-env",
"MXGW_TEST_PW",
"--json",
],
)
assert result.exit_code == 0, result.output
assert "env-secret-pw" not in result.output
assert fake.last_request.command.authenticate_user.verify_user_password == "env-secret-pw"
def test_authenticate_user_requires_a_password() -> None:
result = CliRunner().invoke(
main,
[
"authenticate-user",
"--plaintext",
"--session-id",
"s1",
"--server-handle",
"3",
"--verify-user",
"operator",
"--json",
],
)
assert result.exit_code != 0
assert "password is required" in result.output
def test_write_secured_command_does_not_echo_value_on_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
reply = pb.MxCommandReply(
session_id="s1",
kind=pb.MX_COMMAND_KIND_WRITE_SECURED,
protocol_status=pb.ProtocolStatus(
code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
message="WriteSecured rejected value cli-secret-value",
),
hresult=-1,
)
fake = _FakeInvokeClient(reply)
async def fake_connect(options, **_kwargs):
return fake
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
result = CliRunner().invoke(
main,
[
"write-secured",
"--plaintext",
"--session-id",
"s1",
"--server-handle",
"3",
"--item-handle",
"4",
"--value",
"cli-secret-value",
"--json",
],
)
assert result.exit_code != 0
assert "cli-secret-value" not in result.output
def test_write_secured_and_authenticate_user_commands_are_registered() -> None:
names = set(main.commands)
assert {"write-secured", "authenticate-user"} <= names
+106
View File
@@ -0,0 +1,106 @@
"""Tests for the typed ReplayGap signal on Session.stream_events (CLI-15)."""
from __future__ import annotations
from collections.abc import AsyncIterator
import pytest
from zb_mom_ww_mxgateway import ReplayGap, Session
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
class _FakeClient:
"""Minimal client stub exposing only what Session.stream_events needs."""
def __init__(self, events: list[pb.MxEvent]) -> None:
self._events = events
self.last_request: pb.StreamEventsRequest | None = None
def stream_events_raw(
self,
request: pb.StreamEventsRequest,
) -> AsyncIterator[pb.MxEvent]:
self.last_request = request
async def _gen() -> AsyncIterator[pb.MxEvent]:
for event in self._events:
yield event
return _gen()
def _gap_sentinel(*, requested: int, oldest: int) -> pb.MxEvent:
return pb.MxEvent(
session_id="session-1",
family=pb.MX_EVENT_FAMILY_UNSPECIFIED,
replay_gap=pb.ReplayGap(
requested_after_sequence=requested,
oldest_available_sequence=oldest,
),
)
def _normal_event(worker_sequence: int) -> pb.MxEvent:
return pb.MxEvent(
session_id="session-1",
worker_sequence=worker_sequence,
family=pb.MX_EVENT_FAMILY_ON_DATA_CHANGE,
)
def _session(events: list[pb.MxEvent]) -> tuple[Session, _FakeClient]:
client = _FakeClient(events)
session = Session(client=client, session_id="session-1") # type: ignore[arg-type]
return session, client
@pytest.mark.asyncio
async def test_replay_gap_sentinel_surfaces_as_typed_signal() -> None:
session, _client = _session(
[_gap_sentinel(requested=5, oldest=10), _normal_event(10)],
)
items = [item async for item in session.stream_events(after_worker_sequence=5)]
assert isinstance(items[0], ReplayGap)
assert items[0].requested_after_sequence == 5
assert items[0].oldest_available_sequence == 10
# Resume cursor is oldest_available_sequence - 1 so the next replayed event
# is the oldest the gateway still retains.
assert items[0].resume_after_worker_sequence == 9
# Normal events after the sentinel pass through unchanged as MxEvent.
assert isinstance(items[1], pb.MxEvent)
assert not items[1].HasField("replay_gap")
assert items[1].worker_sequence == 10
@pytest.mark.asyncio
async def test_normal_events_are_unaffected() -> None:
session, _client = _session([_normal_event(1), _normal_event(2)])
items = [item async for item in session.stream_events()]
assert all(isinstance(item, pb.MxEvent) for item in items)
assert [item.worker_sequence for item in items] == [1, 2]
assert not any(isinstance(item, ReplayGap) for item in items)
@pytest.mark.asyncio
async def test_stream_events_forwards_resume_cursor() -> None:
session, client = _session([])
async for _ in session.stream_events(after_worker_sequence=42):
pass
assert client.last_request is not None
assert client.last_request.after_worker_sequence == 42
assert client.last_request.session_id == "session-1"
def test_replay_gap_resume_cursor_never_negative() -> None:
gap = ReplayGap.from_proto(
pb.ReplayGap(requested_after_sequence=0, oldest_available_sequence=0),
)
assert gap.resume_after_worker_sequence == 0
@@ -0,0 +1,227 @@
"""Tests for the typed single-item command helpers (CLI-04).
Covers the parity-critical MXAccess commands promoted from raw ``Invoke`` to
typed async session helpers: ``advise_supervisory``, ``write_secured`` /
``write_secured2``, ``authenticate_user``, ``archestra_user_to_id``, and the
buffered/suspend/activate family. The credential-redaction contract for the
secured/auth helpers is asserted explicitly.
"""
from __future__ import annotations
from typing import Any
import pytest
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient, MxAccessError
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
class FakeUnary:
"""Records requests and pops scripted replies, matching the client's call shape."""
def __init__(self, replies: list[Any]) -> None:
self.replies = replies
self.requests: list[Any] = []
self.metadata: tuple[tuple[str, str], ...] | None = None
async def __call__(
self,
request: Any,
*,
metadata: tuple[tuple[str, str], ...],
) -> Any:
self.requests.append(request)
self.metadata = metadata
return self.replies.pop(0)
class FakeGatewayStub:
"""Minimal stub: a fixed open-session reply plus a scriptable invoke queue."""
def __init__(self, invoke_replies: list[Any]) -> None:
self.open_session = FakeUnary(
[
pb.OpenSessionReply(
session_id="session-1",
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
),
],
)
self.invoke = FakeUnary(invoke_replies)
self.OpenSession = self.open_session
self.Invoke = self.invoke
async def _session_with(invoke_replies: list[Any]):
stub = FakeGatewayStub(invoke_replies)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
return session, stub
def _ok(kind: "pb.MxCommandKind.ValueType", **payload: Any) -> pb.MxCommandReply:
return pb.MxCommandReply(
session_id="session-1",
kind=kind,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
**payload,
)
@pytest.mark.asyncio
async def test_advise_supervisory_sends_typed_command() -> None:
session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY)])
await session.advise_supervisory(12, 34)
command = stub.invoke.requests[0].command
assert command.kind == pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY
assert command.advise_supervisory.server_handle == 12
assert command.advise_supervisory.item_handle == 34
@pytest.mark.asyncio
async def test_authenticate_user_returns_user_id_and_sends_credentials() -> None:
session, stub = await _session_with(
[_ok(pb.MX_COMMAND_KIND_AUTHENTICATE_USER, authenticate_user=pb.AuthenticateUserReply(user_id=77))],
)
user_id = await session.authenticate_user(12, "operator", "s3cr3t-pw")
assert user_id == 77
command = stub.invoke.requests[0].command
assert command.kind == pb.MX_COMMAND_KIND_AUTHENTICATE_USER
assert command.authenticate_user.verify_user == "operator"
# The credential is carried on the wire (redaction is about logs/errors, not the RPC).
assert command.authenticate_user.verify_user_password == "s3cr3t-pw"
@pytest.mark.asyncio
async def test_authenticate_user_scrubs_credential_from_surfaced_error() -> None:
password = "super-secret-pw"
failure = pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
protocol_status=pb.ProtocolStatus(
code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
# Simulate a gateway that unwisely echoed the credential back in the message.
message=f"authentication failed for password {password}",
),
hresult=-1,
)
session, _ = await _session_with([failure])
with pytest.raises(MxAccessError) as captured:
await session.authenticate_user(12, "operator", password)
assert password not in str(captured.value)
assert "[redacted]" in str(captured.value)
@pytest.mark.asyncio
async def test_write_secured_surfaces_native_failure_without_prior_authenticate() -> None:
"""Parity: WriteSecured failing before authenticate/advise-supervisory is surfaced as-is."""
secret_value = "priv-payload"
failure = pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE_SECURED,
protocol_status=pb.ProtocolStatus(
code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
message=f"WriteSecured rejected value {secret_value}",
),
hresult=-2147217407,
)
session, stub = await _session_with([failure])
with pytest.raises(MxAccessError) as captured:
await session.write_secured(12, 34, secret_value, current_user_id=5, verifier_user_id=6)
# Native failure is surfaced (not "fixed") and the raw reply is preserved...
assert captured.value.raw_reply is failure
# ...but the credential-sensitive value is scrubbed from the surfaced message.
assert secret_value not in str(captured.value)
command = stub.invoke.requests[0].command
assert command.kind == pb.MX_COMMAND_KIND_WRITE_SECURED
assert command.write_secured.current_user_id == 5
assert command.write_secured.verifier_user_id == 6
@pytest.mark.asyncio
async def test_write_secured2_sends_value_and_timestamp() -> None:
from datetime import datetime, timezone
session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_WRITE_SECURED2)])
stamp = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
await session.write_secured2(12, 34, 42, stamp, current_user_id=5, verifier_user_id=6)
command = stub.invoke.requests[0].command
assert command.kind == pb.MX_COMMAND_KIND_WRITE_SECURED2
assert command.write_secured2.value.int32_value == 42
assert command.write_secured2.HasField("timestamp_value")
@pytest.mark.asyncio
async def test_archestra_user_to_id_returns_user_id() -> None:
session, stub = await _session_with(
[_ok(pb.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID, archestra_user_to_id=pb.ArchestrAUserToIdReply(user_id=9))],
)
user_id = await session.archestra_user_to_id(12, "guid-123")
assert user_id == 9
assert stub.invoke.requests[0].command.archestra_user_to_id.user_id_guid == "guid-123"
@pytest.mark.asyncio
async def test_add_buffered_item_returns_item_handle() -> None:
session, stub = await _session_with(
[_ok(pb.MX_COMMAND_KIND_ADD_BUFFERED_ITEM, add_buffered_item=pb.AddBufferedItemReply(item_handle=55))],
)
item_handle = await session.add_buffered_item(12, "Object.Attribute", "ctx")
assert item_handle == 55
command = stub.invoke.requests[0].command
assert command.add_buffered_item.item_definition == "Object.Attribute"
assert command.add_buffered_item.item_context == "ctx"
@pytest.mark.asyncio
async def test_set_buffered_update_interval_sends_command() -> None:
session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL)])
await session.set_buffered_update_interval(12, 250)
command = stub.invoke.requests[0].command
assert command.kind == pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL
assert command.set_buffered_update_interval.update_interval_milliseconds == 250
@pytest.mark.asyncio
async def test_suspend_and_activate_return_status() -> None:
session, _ = await _session_with(
[
_ok(
pb.MX_COMMAND_KIND_SUSPEND,
suspend=pb.SuspendReply(status=pb.MxStatusProxy(category=pb.MX_STATUS_CATEGORY_OK)),
),
],
)
status = await session.suspend(12, 34)
assert status.category == pb.MX_STATUS_CATEGORY_OK
session, _ = await _session_with(
[
_ok(
pb.MX_COMMAND_KIND_ACTIVATE,
activate=pb.ActivateReply(status=pb.MxStatusProxy(category=pb.MX_STATUS_CATEGORY_OK)),
),
],
)
status = await session.activate(12, 34)
assert status.category == pb.MX_STATUS_CATEGORY_OK
+63 -12
View File
@@ -137,6 +137,47 @@ redaction. Per-item bulk failures are reported inside each result entry
`invoke_raw` / `client.invoke_raw` escape hatch performs neither check and
returns the unvalidated reply.
## Event Streaming And Reconnect-Replay Gaps
`session.events()` / `session.events_after(after_worker_sequence)` (and the
lower-level `client.stream_events`) return an `EventStream` that yields
`EventItem` values, not bare `MxEvent`s:
```rust
use zb_mom_ww_mxgateway_client::EventItem;
let mut stream = session.events_after(cursor).await?;
while let Some(item) = stream.next().await {
match item? {
EventItem::Event(event) => { /* apply the MXAccess change */ }
EventItem::ReplayGap(gap) => {
// Recent history was evicted — discard local state and re-snapshot,
// then resume without provoking another gap:
let resume = gap.oldest_available_sequence.saturating_sub(1);
stream = session.events_after(resume).await?;
}
}
}
```
Almost every item is a normal `EventItem::Event`. `EventItem::ReplayGap` is a
faithful, typed surfacing of the gateway's reconnect-replay gap sentinel — the
client does not synthesize it. The gateway emits the sentinel at most once, at
the head of a stream **resumed** via `events_after` (`after_worker_sequence`)
when the requested sequence is older than the oldest event still retained in the
session's replay ring: events in the open interval
`(requested_after_sequence, oldest_available_sequence)` were evicted and cannot
be replayed. A `ReplayGap` therefore means "you missed events — discard any
local state and re-snapshot." To resume without a second gap, reconnect with
`events_after(gap.oldest_available_sequence - 1)`, which replays starting at the
first still-retained event. A stream opened from the beginning
(`session.events()` / `events_after(0)`) never produces a `ReplayGap`.
`EventItem` provides `as_event()`, `into_event()`, and `replay_gap()` accessors
for callers that prefer not to `match`. The `mxgw-cli stream-events` subcommand
renders the sentinel as a distinct `REPLAY_GAP …` line (or a `replayGap` JSON
object under `--json` / `--jsonl`).
## Write Semantics And Common Pitfalls
These are MXAccess parity behaviors that surprise new callers. The gateway
@@ -151,26 +192,36 @@ but still need the write attributed to a user id, you must first advise the
item supervisory and then pass that user id on the write. Without the
supervisory advise the `user_id` on a plain write is ignored.
The session exposes `advise`/`un_advise` but not supervisory advise, so send it
through the generic command channel:
The session exposes a typed `advise_supervisory` helper alongside
`advise`/`un_advise`:
```rust
session
.invoke(
MxCommandKind::AdviseSupervisory,
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
server_handle,
item_handle,
}),
)
.await?;
session.advise_supervisory(server_handle, item_handle).await?;
session.write(server_handle, item_handle, value, user_id).await?;
```
The CLI exposes the same command as `advise-supervisory`, and `write` /
`write2` take `--user-id`.
### Verified / secured writes and user resolution
The verified path has typed session helpers too: `authenticate_user` (returns
the resolved MXAccess user id), `archestra_user_to_id`, and
`write_secured` / `write_secured2`. MXAccess parity is preserved — a
`write_secured` issued before the required `authenticate_user` +
`advise_supervisory` (or before a value-bearing body) fails natively and the
failure surfaces as `Error::MxAccess`; it is not smoothed over. Credentials
passed to `authenticate_user` (and secured write payloads) are placed only on
the wire — the client never logs them and never embeds them in an `Error`'s
`Display`/`Debug`; the only error text that can surface (from `tonic::Status`
messages and reply diagnostics) is scrubbed by the credential-redaction seam.
The CLI mirrors these as `authenticate-user` (password via `--password` or the
`--password-env` env var, never echoed) and `write-secured`.
The remaining single-item command helpers round out MXAccess parity:
`unregister`, `suspend` / `activate` (each returns the operation's
`MxStatus`), `add_buffered_item`, and `set_buffered_update_interval`.
### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an
+214 -24
View File
@@ -21,15 +21,14 @@ use serde_json::Value;
use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode};
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AcknowledgeAlarmRequest, AdviseSupervisoryCommand, AlarmFeedMessage,
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily,
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamAlarmsRequest,
StreamEventsRequest, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry,
WriteSecuredBulkEntry,
alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxValue as ProtoMxValue,
OpenSessionRequest, PingCommand, StreamAlarmsRequest, StreamEventsRequest, Write2BulkEntry,
WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
};
use zb_mom_ww_mxgateway_client::{
next_correlation_id, ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue,
MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient,
MxValue, MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
};
const MAX_AGGREGATE_EVENTS: usize = 10_000;
@@ -118,6 +117,67 @@ enum Command {
#[arg(long)]
json: bool,
},
/// Release a `ServerHandle` (and the items advised under it) via
/// MXAccess `Unregister`.
Unregister {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long)]
json: bool,
},
/// Resolve an MXAccess user id from a credential via `AuthenticateUser`.
/// The password is read from `--password` or, if omitted, from the
/// environment variable named by `--password-env`; it is never echoed to
/// stdout/stderr.
AuthenticateUser {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long)]
verify_user: String,
/// Verifier password. Prefer `--password-env` so the secret never
/// appears in the process command line.
#[arg(long)]
password: Option<String>,
/// Name of the environment variable holding the verifier password.
/// Used only when `--password` is not supplied.
#[arg(long, default_value = "MXGATEWAY_VERIFY_PASSWORD")]
password_env: String,
#[arg(long)]
json: bool,
},
/// Single credential-verified write via MXAccess `WriteSecured`.
///
/// Parity note: this fails natively unless the session has first run
/// `authenticate-user` + `advise-supervisory` and the item carries a
/// value-bearing body — the native failure is surfaced, not hidden.
WriteSecured {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long)]
item_handle: i32,
#[arg(long, value_enum)]
value_type: CliValueType,
#[arg(long)]
value: String,
#[arg(long, default_value_t = 0)]
current_user_id: i32,
#[arg(long, default_value_t = 0)]
verifier_user_id: i32,
#[arg(long)]
json: bool,
},
SubscribeBulk {
#[command(flatten)]
connection: ConnectionArgs,
@@ -669,18 +729,69 @@ async fn dispatch(command: Command) -> Result<(), Error> {
} => {
let session = session_for(connection, session_id).await?;
session
.invoke(
MxCommandKind::AdviseSupervisory,
zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::AdviseSupervisory(
AdviseSupervisoryCommand {
server_handle,
item_handle,
},
),
)
.advise_supervisory(server_handle, item_handle)
.await?;
print_ok("advise-supervisory", json);
}
Command::Unregister {
connection,
session_id,
server_handle,
json,
} => {
let session = session_for(connection, session_id).await?;
session.unregister(server_handle).await?;
print_ok("unregister", json);
}
Command::AuthenticateUser {
connection,
session_id,
server_handle,
verify_user,
password,
password_env,
json,
} => {
// Resolve the credential from --password or the named env var.
// The password is passed straight to the typed helper and is never
// echoed to stdout/stderr or embedded in an error message.
let verify_user_password = password
.or_else(|| env::var(&password_env).ok())
.ok_or_else(|| Error::InvalidArgument {
name: "password".to_owned(),
detail: format!(
"supply --password or set the environment variable `{password_env}`"
),
})?;
let session = session_for(connection, session_id).await?;
let user_id = session
.authenticate_user(server_handle, &verify_user, &verify_user_password)
.await?;
print_handle("userId", user_id, json);
}
Command::WriteSecured {
connection,
session_id,
server_handle,
item_handle,
value_type,
value,
current_user_id,
verifier_user_id,
json,
} => {
let session = session_for(connection, session_id).await?;
session
.write_secured(
server_handle,
item_handle,
current_user_id,
verifier_user_id,
parse_value(value_type, &value)?,
)
.await?;
print_ok("write-secured", json);
}
Command::SubscribeBulk {
connection,
session_id,
@@ -886,17 +997,43 @@ async fn dispatch(command: Command) -> Result<(), Error> {
let mut events: Vec<Value> = Vec::new();
let mut event_count = 0usize;
while event_count < max_events {
let Some(event) = stream.next().await else {
let Some(item) = stream.next().await else {
break;
};
let event = event?;
let item = item?;
event_count += 1;
if jsonl {
println!("{}", event_to_json(&event));
} else if json {
events.push(event_to_json(&event));
} else {
println!("{} {}", event.worker_sequence, event.family);
match item {
EventItem::Event(event) => {
if jsonl {
println!("{}", event_to_json(&event));
} else if json {
events.push(event_to_json(&event));
} else {
println!("{} {}", event.worker_sequence, event.family);
}
}
// Reconnect-replay gap sentinel: recent history was evicted
// before this resumed stream could replay it. Render it as a
// distinct row so the caller can re-snapshot and resume with
// `oldest_available_sequence - 1`.
EventItem::ReplayGap(gap) => {
let value = json!({
"replayGap": {
"requestedAfterSequence": gap.requested_after_sequence,
"oldestAvailableSequence": gap.oldest_available_sequence,
}
});
if jsonl {
println!("{value}");
} else if json {
events.push(value);
} else {
println!(
"REPLAY_GAP requested_after={} oldest_available={}",
gap.requested_after_sequence, gap.oldest_available_sequence
);
}
}
}
}
if json {
@@ -2463,6 +2600,59 @@ mod tests {
assert_eq!(value["workerProtocolVersion"], 1);
}
#[test]
fn parses_authenticate_user_command_with_password_env() {
let parsed = Cli::try_parse_from([
"mxgw",
"authenticate-user",
"--session-id",
"session-1",
"--server-handle",
"7",
"--verify-user",
"verifier",
"--password-env",
"MY_PW_VAR",
]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_write_secured_command() {
let parsed = Cli::try_parse_from([
"mxgw",
"write-secured",
"--session-id",
"session-1",
"--server-handle",
"12",
"--item-handle",
"34",
"--value-type",
"int32",
"--value",
"5",
"--current-user-id",
"1",
"--verifier-user-id",
"2",
]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_unregister_command() {
let parsed = Cli::try_parse_from([
"mxgw",
"unregister",
"--session-id",
"session-1",
"--server-handle",
"12",
]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_stream_alarms_command() {
let parsed = Cli::try_parse_from([
+121 -8
View File
@@ -18,7 +18,7 @@ use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGa
use crate::generated::mxaccess_gateway::v1::{
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AlarmFeedMessage,
CloseSessionReply, CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent,
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest,
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, ReplayGap, StreamAlarmsRequest,
StreamEventsRequest,
};
use crate::options::{build_tls_config, ClientOptions};
@@ -28,11 +28,120 @@ use crate::session::Session;
/// [`GatewayClient`] uses internally.
pub type RawGatewayClient = MxAccessGatewayClient<InterceptedService<Channel, AuthInterceptor>>;
/// Pinned, boxed [`MxEvent`] stream returned by
/// [`GatewayClient::stream_events`]. Errors are pre-mapped from
/// `tonic::Status` to [`Error`]; dropping the stream cancels the call.
/// One item yielded by the per-session event stream returned by
/// [`GatewayClient::stream_events`].
///
/// Almost every item is an ordinary MXAccess event ([`EventItem::Event`]).
/// The one exception is the reconnect-replay gap sentinel
/// ([`EventItem::ReplayGap`]): the gateway emits it at most once, at the head
/// of a stream that was *resumed* via
/// [`Session::events_after`](crate::session::Session::events_after)
/// (`StreamEventsRequest.after_worker_sequence`) when the requested sequence is
/// older than the oldest event still retained in the session replay ring — i.e.
/// events were evicted and cannot be replayed.
///
/// The client does **not** synthesize this signal: it faithfully forwards the
/// gateway's sentinel `MxEvent` (whose `replay_gap` field is set), and only
/// makes it a distinct, typed variant so consumers can `match` on it instead of
/// inspecting a field on a value that otherwise looks like a normal event.
///
/// # Reacting to a gap
///
/// A [`EventItem::ReplayGap`] means "you missed events — discard any local
/// state and re-snapshot." The events in the open interval
/// `(requested_after_sequence, oldest_available_sequence)` are gone. To resume
/// the stream without provoking another gap, reconnect with
/// [`Session::events_after`](crate::session::Session::events_after) passing
/// `oldest_available_sequence - 1`, which replays starting at the first still
/// retained event (`oldest_available_sequence`):
///
/// ```no_run
/// # use zb_mom_ww_mxgateway_client::{EventItem, Session};
/// # use futures_util::StreamExt;
/// # async fn run(session: Session, cursor: u64) -> Result<(), zb_mom_ww_mxgateway_client::Error> {
/// let mut stream = session.events_after(cursor).await?;
/// while let Some(item) = stream.next().await {
/// match item? {
/// EventItem::Event(event) => {
/// let _ = event; // apply the change
/// }
/// EventItem::ReplayGap(gap) => {
/// // Local state is stale — re-snapshot, then resume without a gap.
/// let resume_cursor = gap.oldest_available_sequence.saturating_sub(1);
/// stream = session.events_after(resume_cursor).await?;
/// }
/// }
/// }
/// # Ok(())
/// # }
/// ```
// The `Event` variant is the hot path (nearly every stream item) and is the
// large one; the rare `ReplayGap` sentinel is small. Boxing `Event` to equalize
// the variants would add a heap allocation to every streamed event — a
// regression versus the prior `Result<MxEvent, Error>` surface, which already
// moved `MxEvent` by value. Keep the common path allocation-free.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq)]
pub enum EventItem {
/// A normal MXAccess event forwarded from the worker.
Event(MxEvent),
/// The reconnect-replay gap sentinel — recent event history was evicted
/// before this resumed stream could replay it. See [`EventItem`] for how
/// to react.
ReplayGap(ReplayGap),
}
impl EventItem {
/// Classify an incoming `MxEvent` into the typed stream item.
///
/// A present `replay_gap` promotes the event to [`EventItem::ReplayGap`];
/// otherwise it is an ordinary [`EventItem::Event`]. The sentinel is never
/// dropped and never surfaced as a normal event.
fn from_event(mut event: MxEvent) -> Self {
match event.replay_gap.take() {
Some(gap) => EventItem::ReplayGap(gap),
None => EventItem::Event(event),
}
}
/// Borrow the inner [`MxEvent`] when this item is a normal event, or
/// `None` when it is the [`EventItem::ReplayGap`] sentinel.
#[must_use]
pub fn as_event(&self) -> Option<&MxEvent> {
match self {
EventItem::Event(event) => Some(event),
EventItem::ReplayGap(_) => None,
}
}
/// Borrow the [`ReplayGap`] when this item is the reconnect-replay gap
/// sentinel, or `None` for a normal event.
#[must_use]
pub fn replay_gap(&self) -> Option<&ReplayGap> {
match self {
EventItem::ReplayGap(gap) => Some(gap),
EventItem::Event(_) => None,
}
}
/// Consume the item and return the inner [`MxEvent`] when it is a normal
/// event, or `None` for the [`EventItem::ReplayGap`] sentinel.
#[must_use]
pub fn into_event(self) -> Option<MxEvent> {
match self {
EventItem::Event(event) => Some(event),
EventItem::ReplayGap(_) => None,
}
}
}
/// Pinned, boxed [`EventItem`] stream returned by
/// [`GatewayClient::stream_events`]. Each item is either a normal
/// [`EventItem::Event`] or the [`EventItem::ReplayGap`] reconnect-replay
/// sentinel. Errors are pre-mapped from `tonic::Status` to [`Error`]; dropping
/// the stream cancels the call.
pub type EventStream =
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<MxEvent, Error>> + Send + 'static>>;
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<EventItem, Error>> + Send + 'static>>;
/// Pinned, boxed [`ActiveAlarmSnapshot`] stream returned by
/// [`GatewayClient::query_active_alarms`]. Errors are pre-mapped from
@@ -190,8 +299,12 @@ impl GatewayClient {
/// Open the server-streaming `StreamEvents` RPC.
///
/// The returned [`EventStream`] yields `MxEvent` messages as the worker
/// produces them. Dropping the stream cancels the gRPC call cooperatively.
/// The returned [`EventStream`] yields [`EventItem`] values as the worker
/// produces them: ordinary MXAccess events as [`EventItem::Event`], and the
/// gateway's reconnect-replay gap sentinel — set only on resumed streams
/// whose requested sequence predates the retained replay history — as
/// [`EventItem::ReplayGap`]. Dropping the stream cancels the gRPC call
/// cooperatively.
///
/// # Errors
///
@@ -201,7 +314,7 @@ impl GatewayClient {
let mut client = self.inner.clone();
let response = client.stream_events(self.stream_request(request)).await?;
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
result.map_err(Error::from)
result.map(EventItem::from_event).map_err(Error::from)
});
Ok(Box::pin(stream))
+3 -1
View File
@@ -24,12 +24,14 @@ pub mod version;
#[doc(inline)]
pub use auth::{ApiKey, AuthInterceptor};
#[doc(inline)]
pub use client::{AlarmFeedStream, EventStream, GatewayClient};
pub use client::{AlarmFeedStream, EventItem, EventStream, GatewayClient};
#[doc(inline)]
pub use error::{CommandError, Error, MxAccessError};
#[doc(inline)]
pub use galaxy::{DeployEventStream, GalaxyClient};
#[doc(inline)]
pub use generated::mxaccess_gateway::v1::ReplayGap;
#[doc(inline)]
pub use options::ClientOptions;
#[doc(inline)]
pub use session::{next_correlation_id, Session};
+373 -9
View File
@@ -15,16 +15,19 @@ use crate::error::{ensure_protocol_success, Error};
use crate::generated::mxaccess_gateway::v1::mx_command::Payload;
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
use crate::generated::mxaccess_gateway::v1::{
AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand,
BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply,
MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue,
OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand,
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand,
UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry,
Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand,
WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry,
ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand,
MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement,
MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand,
RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand,
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
UnAdviseItemBulkCommand, UnregisterCommand, UnsubscribeBulkCommand, Write2BulkCommand,
Write2BulkEntry, Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand,
WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command,
WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecuredCommand,
};
use crate::value::MxValue;
use crate::value::{MxStatus, MxValue};
const MAX_BULK_ITEMS: usize = 1_000;
@@ -129,6 +132,25 @@ impl Session {
register_server_handle(&reply)
}
/// Run MXAccess `Unregister` to release the given `ServerHandle` and the
/// items advised under it. Mirrors [`Session::register`]; the worker
/// returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`] if the worker reports a non-OK protocol
/// status and [`Error::MxAccess`] if MXAccess itself rejects the
/// unregister (negative `hresult` / non-success status), plus the usual
/// transport/status errors.
pub async fn unregister(&self, server_handle: i32) -> Result<(), Error> {
self.invoke(
MxCommandKind::Unregister,
Payload::Unregister(UnregisterCommand { server_handle }),
)
.await?;
Ok(())
}
/// Run MXAccess `AddItem` against `server_handle` and return the
/// assigned `ItemHandle`.
///
@@ -230,6 +252,133 @@ impl Session {
Ok(())
}
/// Run MXAccess `AdviseSupervisory` to start supervisory-mode change
/// notifications for the given item. Mirrors [`Session::advise`]; the
/// worker returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status and
/// [`Error::MxAccess`] when MXAccess reports a negative `hresult` /
/// non-success status, plus the usual transport/status errors.
pub async fn advise_supervisory(
&self,
server_handle: i32,
item_handle: i32,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::AdviseSupervisory,
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
server_handle,
item_handle,
}),
)
.await?;
Ok(())
}
/// Run MXAccess `Suspend` on the given item and return the native
/// `MXSTATUS_PROXY` the worker reports for the operation.
///
/// A top-level MXAccess failure (negative `hresult` or a non-success
/// top-level status) still surfaces as [`Error::MxAccess`] via the shared
/// reply validation; the returned [`MxStatus`] is the per-operation status
/// carried in the `SuspendReply` payload.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] on an MXAccess-level failure, and
/// [`Error::MalformedReply`] if the OK reply lacks the `Suspend` payload,
/// plus the usual transport/status errors.
pub async fn suspend(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
let reply = self
.invoke(
MxCommandKind::Suspend,
Payload::Suspend(SuspendCommand {
server_handle,
item_handle,
}),
)
.await?;
suspend_status(reply)
}
/// Run MXAccess `Activate` on the given item and return the native
/// `MXSTATUS_PROXY` the worker reports for the operation. See
/// [`Session::suspend`] for the status/error contract.
///
/// # Errors
///
/// Same conditions as [`Session::suspend`] (with the `Activate` payload).
pub async fn activate(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
let reply = self
.invoke(
MxCommandKind::Activate,
Payload::Activate(ActivateCommand {
server_handle,
item_handle,
}),
)
.await?;
activate_status(reply)
}
/// Run MXAccess `AddBufferedItem` against `server_handle` and return the
/// assigned `ItemHandle`. Mirrors [`Session::add_item2`] — the buffered
/// item carries a caller-supplied context string.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] when the worker or
/// MXAccess rejects the item, [`Error::MalformedReply`] if the OK reply
/// lacks the item handle, plus the usual transport/status errors.
pub async fn add_buffered_item(
&self,
server_handle: i32,
item_definition: &str,
item_context: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::AddBufferedItem,
Payload::AddBufferedItem(AddBufferedItemCommand {
server_handle,
item_definition: item_definition.to_owned(),
item_context: item_context.to_owned(),
}),
)
.await?;
add_buffered_item_handle(&reply)
}
/// Run MXAccess `SetBufferedUpdateInterval` for `server_handle`. The
/// worker returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
/// status or an MXAccess-level failure, plus the usual transport/status
/// errors.
pub async fn set_buffered_update_interval(
&self,
server_handle: i32,
update_interval_milliseconds: i32,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::SetBufferedUpdateInterval,
Payload::SetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand {
server_handle,
update_interval_milliseconds,
}),
)
.await?;
Ok(())
}
/// Bulk variant of [`Session::add_item`]. Each tag address yields one
/// `SubscribeResult` in the returned vector.
///
@@ -628,8 +777,151 @@ impl Session {
Ok(())
}
/// Run MXAccess `WriteSecured` (single credential-verified write, no
/// caller-supplied timestamp).
///
/// **MXAccess parity:** `WriteSecured` failing before a prior
/// [`Session::authenticate_user`] + [`Session::advise_supervisory`], or
/// before a value-bearing body, is the native contract, not a client bug —
/// the failure surfaces as [`Error::MxAccess`] (negative `hresult`) and is
/// **not** smoothed over. The `value` is credential-sensitive: it is placed
/// only in the wire command and is never logged or embedded in an error
/// message.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] when MXAccess rejects the secured write, plus the
/// usual transport/status errors.
pub async fn write_secured(
&self,
server_handle: i32,
item_handle: i32,
current_user_id: i32,
verifier_user_id: i32,
value: MxValue,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::WriteSecured,
Payload::WriteSecured(WriteSecuredCommand {
server_handle,
item_handle,
current_user_id,
verifier_user_id,
value: Some(value.into_proto()),
}),
)
.await?;
Ok(())
}
/// Run MXAccess `WriteSecured2` (credential-verified write with a
/// caller-supplied timestamp). See [`Session::write_secured`] for the
/// parity and credential-handling contract.
///
/// # Errors
///
/// Same conditions as [`Session::write_secured`].
pub async fn write_secured2(
&self,
server_handle: i32,
item_handle: i32,
current_user_id: i32,
verifier_user_id: i32,
value: MxValue,
timestamp_value: MxValue,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::WriteSecured2,
Payload::WriteSecured2(WriteSecured2Command {
server_handle,
item_handle,
current_user_id,
verifier_user_id,
value: Some(value.into_proto()),
timestamp_value: Some(timestamp_value.into_proto()),
}),
)
.await?;
Ok(())
}
/// Run MXAccess `AuthenticateUser` and return the resolved MXAccess user
/// id.
///
/// **Credential handling:** `verify_user_password` is a raw MXAccess
/// credential. It is placed only in the wire command; this helper never
/// logs it and never embeds it in an [`Error`] — the only error text that
/// can surface comes from `tonic::Status` messages and the reply's
/// diagnostic fields, both of which are scrubbed by the client's
/// credential-redaction seam (see [`crate::error`]). The reply itself
/// carries no echo of the credential.
///
/// **MXAccess parity:** a failed authentication is a native outcome, not a
/// client error to paper over — it surfaces as [`Error::MxAccess`]
/// (negative `hresult`) with the credential absent from the message.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] when MXAccess rejects the credential,
/// [`Error::MalformedReply`] if the OK reply lacks the user id, plus the
/// usual transport/status errors.
pub async fn authenticate_user(
&self,
server_handle: i32,
verify_user: &str,
verify_user_password: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::AuthenticateUser,
Payload::AuthenticateUser(AuthenticateUserCommand {
server_handle,
verify_user: verify_user.to_owned(),
verify_user_password: verify_user_password.to_owned(),
}),
)
.await?;
authenticate_user_id(&reply)
}
/// Run MXAccess `ArchestrAUserToId` to resolve an ArchestrA user GUID to
/// its MXAccess user id.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
/// status or MXAccess-level failure, [`Error::MalformedReply`] if the OK
/// reply lacks the user id, plus the usual transport/status errors.
pub async fn archestra_user_to_id(
&self,
server_handle: i32,
user_id_guid: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::ArchestraUserToId,
Payload::ArchestraUserToId(ArchestrAUserToIdCommand {
server_handle,
user_id_guid: user_id_guid.to_owned(),
}),
)
.await?;
archestra_user_id(&reply)
}
/// Open the per-session event stream from the beginning.
///
/// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
/// values — normal MXAccess events as
/// [`EventItem::Event`](crate::EventItem::Event). A stream opened from the
/// beginning never produces a
/// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap); that sentinel
/// only appears on a resumed stream (see [`Session::events_after`]).
///
/// # Errors
///
/// Returns the `tonic::Status` mapped through [`Error::from`] when the
@@ -642,6 +934,15 @@ impl Session {
/// `worker_sequence` is greater than `after_worker_sequence`. Pass `0`
/// to receive every buffered event.
///
/// If `after_worker_sequence` predates the oldest event still retained in
/// the gateway's replay ring, the stream opens with a single
/// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap) sentinel: recent
/// history was evicted and cannot be replayed, so the caller must discard
/// any local state and re-snapshot. To resume without provoking another
/// gap, call this method again with
/// `gap.oldest_available_sequence - 1`. See
/// [`EventItem`](crate::EventItem) for the full contract.
///
/// # Errors
///
/// Same conditions as [`Session::events`].
@@ -753,6 +1054,69 @@ fn add_item2_handle(reply: &MxCommandReply) -> Result<i32, Error> {
}
}
fn add_buffered_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AddBufferedItem(add_buffered)) => {
Ok(add_buffered.item_handle)
}
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail:
"add_buffered_item reply lacked an item_handle payload or int32 return_value"
.to_owned(),
}),
}
}
fn authenticate_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AuthenticateUser(authenticate)) => Ok(authenticate.user_id),
_ => Err(Error::MalformedReply {
detail: "authenticate_user reply lacked an AuthenticateUser payload".to_owned(),
}),
}
}
fn archestra_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::ArchestraUserToId(archestra)) => Ok(archestra.user_id),
_ => Err(Error::MalformedReply {
detail: "archestra_user_to_id reply lacked an ArchestraUserToId payload".to_owned(),
}),
}
}
fn suspend_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
match reply.payload {
Some(mx_command_reply::Payload::Suspend(suspend)) => suspend
.status
.map(MxStatus::from_proto)
.ok_or_else(|| Error::MalformedReply {
detail: "suspend reply payload lacked a status entry".to_owned(),
}),
_ => Err(Error::MalformedReply {
detail: "suspend reply did not carry a Suspend payload".to_owned(),
}),
}
}
fn activate_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
match reply.payload {
Some(mx_command_reply::Payload::Activate(activate)) => activate
.status
.map(MxStatus::from_proto)
.ok_or_else(|| Error::MalformedReply {
detail: "activate reply payload lacked a status entry".to_owned(),
}),
_ => Err(Error::MalformedReply {
detail: "activate reply did not carry an Activate payload".to_owned(),
}),
}
}
enum BulkReplyKind {
AddItem,
AdviseItem,
+3 -2
View File
@@ -3,8 +3,9 @@
//! The protocol versions track the values the gateway and worker negotiate on
//! `OpenSession` and let test harnesses cross-check the wire contract.
/// Semantic version of this Rust client crate. Mirrors `Cargo.toml`.
pub const CLIENT_VERSION: &str = "0.1.0-dev";
/// Semantic version of this Rust client crate. Sourced from `Cargo.toml` at
/// compile time so the two cannot drift.
pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Public gateway gRPC protocol version this client targets.
pub const GATEWAY_PROTOCOL_VERSION: u32 = 3;
+270 -14
View File
@@ -23,17 +23,18 @@ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_value::Kind;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot,
AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind,
BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult,
CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent,
MxEventFamily, MxSparseArray, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource,
MxValue, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, SessionState, StreamAlarmsRequest,
StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry, WriteCommand,
WriteSecured2BulkEntry, WriteSecuredBulkEntry,
AuthenticateUserCommand, AuthenticateUserReply, BulkReadReply, BulkReadResult,
BulkSubscribeReply, BulkWriteReply, BulkWriteResult, CloseSessionReply, CloseSessionRequest,
MxCommandKind, MxCommandReply, MxDataType, MxEvent, MxEventFamily, MxSparseArray,
MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue,
OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, ReplayGap, SessionState,
StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry,
WriteCommand, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
};
use zb_mom_ww_mxgateway_client::{
next_correlation_id, ApiKey, ClientOptions, CommandError, Error, GatewayClient, MxStatus,
MxValue as ClientMxValue, MxValueProjection,
next_correlation_id, ApiKey, ClientOptions, CommandError, Error, EventItem, GatewayClient,
MxStatus, MxValue as ClientMxValue, MxValueProjection,
};
#[tokio::test]
@@ -128,8 +129,28 @@ async fn event_stream_preserves_order_and_drop_cancels_server_stream() {
.await
.unwrap();
assert_eq!(stream.next().await.unwrap().unwrap().worker_sequence, 1);
assert_eq!(stream.next().await.unwrap().unwrap().worker_sequence, 2);
assert_eq!(
stream
.next()
.await
.unwrap()
.unwrap()
.as_event()
.unwrap()
.worker_sequence,
1
);
assert_eq!(
stream
.next()
.await
.unwrap()
.unwrap()
.as_event()
.unwrap()
.worker_sequence,
2
);
drop(stream);
for _ in 0..20 {
@@ -142,6 +163,55 @@ async fn event_stream_preserves_order_and_drop_cancels_server_stream() {
assert!(state.stream_dropped.load(Ordering::SeqCst));
}
#[tokio::test]
async fn replay_gap_sentinel_surfaces_as_typed_event_item() {
let state = Arc::new(FakeState::default());
// Script a resumed stream: the reconnect-replay gap sentinel at the head
// (family UNSPECIFIED, no body, `replay_gap` set) followed by a normal
// event. The client must promote the sentinel to `EventItem::ReplayGap`
// and leave the following event as a normal `EventItem::Event`.
*state.stream_events_script.lock().await = Some(vec![
MxEvent {
replay_gap: Some(ReplayGap {
requested_after_sequence: 5,
oldest_available_sequence: 42,
}),
..MxEvent::default()
},
event(42),
]);
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let mut stream = client
.stream_events(StreamEventsRequest {
session_id: "session-fixture".to_owned(),
after_worker_sequence: 5,
})
.await
.unwrap();
// First item is the typed gap sentinel, not a normal event.
let first = stream.next().await.unwrap().unwrap();
match &first {
EventItem::ReplayGap(gap) => {
assert_eq!(gap.requested_after_sequence, 5);
assert_eq!(gap.oldest_available_sequence, 42);
}
EventItem::Event(_) => panic!("expected a ReplayGap sentinel, got a normal event"),
}
// Accessor helpers reflect the variant.
assert!(first.as_event().is_none());
assert_eq!(first.replay_gap().unwrap().oldest_available_sequence, 42);
// The normal event that follows is unaffected.
let second = stream.next().await.unwrap().unwrap();
assert_eq!(second.as_event().unwrap().worker_sequence, 42);
assert!(second.replay_gap().is_none());
}
#[tokio::test]
async fn acknowledge_alarm_returns_reply_with_native_status() {
let state = Arc::new(FakeState::default());
@@ -555,6 +625,133 @@ async fn write_secured2_bulk_round_trips_through_the_fake_gateway() {
assert_eq!(*last_command, Some(MxCommandKind::WriteSecured2Bulk as i32));
}
#[tokio::test]
async fn advise_supervisory_round_trips_and_sends_advise_supervisory_kind() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let session = client.session("session-fixture");
session.advise_supervisory(12, 34).await.unwrap();
let last_command = state.last_command_kind.lock().await;
assert_eq!(*last_command, Some(MxCommandKind::AdviseSupervisory as i32));
}
#[tokio::test]
async fn unregister_round_trips_and_sends_unregister_kind() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let session = client.session("session-fixture");
session.unregister(12).await.unwrap();
let last_command = state.last_command_kind.lock().await;
assert_eq!(*last_command, Some(MxCommandKind::Unregister as i32));
}
#[tokio::test]
async fn write_secured_surfaces_native_mxaccess_failure_and_redacts_diagnostic() {
// MXAccess parity: WriteSecured failing (e.g. before authenticate +
// advise-supervisory) is a native outcome, surfaced — not smoothed over.
// The scripted reply carries an Ok protocol envelope but a negative
// hresult, so this also proves the typed helper runs ensure_mxaccess_success.
let state = Arc::new(FakeState::default());
*state.invoke_override.lock().await = Some(InvokeOverride::MxAccessFailure);
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let session = client.session("session-fixture");
let error = session
.write_secured(12, 34, 0, 0, ClientMxValue::int32(1))
.await
.unwrap_err();
let Error::MxAccess(mx_access) = &error else {
panic!("write_secured must surface the native failure as Error::MxAccess: {error:?}");
};
assert_eq!(mx_access.reply().hresult, Some(-2_147_217_900));
let rendered = error.to_string();
assert!(rendered.contains("<redacted>"), "diagnostic: {rendered}");
assert!(
!rendered.contains("leaked_secret"),
"credential-shaped diagnostic must be scrubbed: {rendered}"
);
let last_command = state.last_command_kind.lock().await;
assert_eq!(*last_command, Some(MxCommandKind::WriteSecured as i32));
}
#[tokio::test]
async fn authenticate_user_returns_user_id_and_transmits_credential_on_wire() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let session = client.session("session-fixture");
let user_id = session
.authenticate_user(7, "verifier", "sup3r-s3cret-pw")
.await
.unwrap();
assert_eq!(user_id, 4242);
let captured = state
.last_authenticate_user
.lock()
.await
.take()
.expect("fake should have captured an AuthenticateUserCommand");
assert_eq!(captured.server_handle, 7);
assert_eq!(captured.verify_user, "verifier");
// The credential must reach the wire so authentication can succeed...
assert_eq!(captured.verify_user_password, "sup3r-s3cret-pw");
let last_command = state.last_command_kind.lock().await;
assert_eq!(*last_command, Some(MxCommandKind::AuthenticateUser as i32));
}
#[tokio::test]
async fn authenticate_user_keeps_credentials_out_of_surfaced_errors() {
// ...but a native authentication failure must never leak the credential
// into the error's Display or Debug rendering.
let state = Arc::new(FakeState::default());
*state.invoke_override.lock().await = Some(InvokeOverride::MxAccessFailure);
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let session = client.session("session-fixture");
let password = "unique-credential-9f3b2";
let error = session
.authenticate_user(7, "verifier", password)
.await
.unwrap_err();
assert!(
matches!(error, Error::MxAccess(_)),
"native auth failure should surface as Error::MxAccess: {error:?}"
);
let display = error.to_string();
let debug = format!("{error:?}");
assert!(
!display.contains(password),
"credential leaked into Display: {display}"
);
assert!(
!debug.contains(password),
"credential leaked into Debug: {debug}"
);
}
#[tokio::test]
async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
let state = Arc::new(FakeState::default());
@@ -663,6 +860,10 @@ struct FakeState {
/// Captures the last `WriteCommand` payload received, populated when the
/// `WriteOk` override is active. Used by `write_array_elements` e2e test.
last_write_command: Mutex<Option<WriteCommand>>,
/// Captures the last `AuthenticateUserCommand` payload received, populated
/// by the `AuthenticateUser` happy-path handler so a test can confirm the
/// credential reaches the wire (but never a surfaced error).
last_authenticate_user: Mutex<Option<AuthenticateUserCommand>>,
stream_dropped: Arc<AtomicBool>,
/// Optional per-test override that pins the fake's `Invoke` handler to
/// a specific reply shape (or `Err(Status)`). The default of `None`
@@ -672,6 +873,11 @@ struct FakeState {
/// handler to emit a synthetic ConditionRefresh -> snapshot_complete
/// -> transition sequence.
stream_alarms_script: Mutex<Option<Vec<AlarmFeedMessage>>>,
/// Optional per-test override that pins the fake's `StreamEvents`
/// handler to emit a scripted `MxEvent` sequence (e.g. a `replay_gap`
/// sentinel followed by a normal event). When `None`, the handler falls
/// back to the default `event(1)` / `event(2)` pair.
stream_events_script: Mutex<Option<Vec<MxEvent>>>,
}
/// Per-test override for the fake's `Invoke` handler.
@@ -691,6 +897,12 @@ enum InvokeOverride {
/// and capture the decoded `WriteCommand` in
/// `FakeState::last_write_command` for inspection.
WriteOk,
/// Reply with an `Ok` protocol envelope but a negative `hresult` and a
/// non-success status entry carrying a credential-shaped diagnostic. This
/// mimics the worker's COMException path (e.g. `WriteSecured` /
/// `AuthenticateUser` rejected by MXAccess) so the client's
/// `ensure_mxaccess_success` check is exercised on the typed helper path.
MxAccessFailure,
}
#[derive(Clone)]
@@ -772,6 +984,27 @@ impl MxAccessGateway for FakeGateway {
..MxCommandReply::default()
})),
InvokeOverride::Unavailable(message) => Err(Status::unavailable(message)),
InvokeOverride::MxAccessFailure => Ok(Response::new(MxCommandReply {
session_id: request.session_id,
correlation_id: "fake-correlation".to_owned(),
kind,
// Protocol envelope succeeds; MXAccess itself failed.
protocol_status: Some(ok_status("command ok")),
// 0x80040E14 (a COM failure) as a signed 32-bit value.
hresult: Some(-2_147_217_900),
statuses: vec![MxStatusProxy {
success: 0,
category: MxStatusCategory::SecurityError as i32,
detected_by: MxStatusSource::RespondingLmx as i32,
detail: 123,
// A credential-shaped token that must be scrubbed from
// any surfaced diagnostic text.
diagnostic_text: "denied for mxgw_leaked_secret".to_owned(),
..MxStatusProxy::default()
}],
payload: None,
..MxCommandReply::default()
})),
InvokeOverride::WriteOk => {
// Extract and capture the WriteCommand payload so the test
// can assert on server_handle, item_handle, user_id, and value.
@@ -903,6 +1136,26 @@ impl MxAccessGateway for FakeGateway {
)));
}
if kind == MxCommandKind::AuthenticateUser as i32 {
// Capture the transmitted command so a test can confirm the
// credential reaches the wire but never an error message.
if let Some(mx_command::Payload::AuthenticateUser(auth)) =
request.command.and_then(|command| command.payload)
{
*self.state.last_authenticate_user.lock().await = Some(auth);
}
return Ok(Response::new(MxCommandReply {
session_id: request.session_id,
correlation_id: "fake-correlation".to_owned(),
kind,
protocol_status: Some(ok_status("command ok")),
payload: Some(mx_command_reply::Payload::AuthenticateUser(
AuthenticateUserReply { user_id: 4242 },
)),
..MxCommandReply::default()
}));
}
Ok(Response::new(MxCommandReply {
session_id: request.session_id,
correlation_id: "fake-correlation".to_owned(),
@@ -921,9 +1174,12 @@ impl MxAccessGateway for FakeGateway {
&self,
_request: Request<StreamEventsRequest>,
) -> Result<Response<Self::StreamEventsStream>, Status> {
let (sender, receiver) = mpsc::channel(4);
sender.send(Ok(event(1))).await.unwrap();
sender.send(Ok(event(2))).await.unwrap();
let script = self.state.stream_events_script.lock().await.take();
let events = script.unwrap_or_else(|| vec![event(1), event(2)]);
let (sender, receiver) = mpsc::channel(events.len().max(1));
for event in events {
sender.send(Ok(event)).await.unwrap();
}
Ok(Response::new(DropAwareStream {
inner: ReceiverStream::new(receiver),