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:
@@ -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
@@ -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`). |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
+119
-7
@@ -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);
|
||||
|
||||
+67
@@ -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<>();
|
||||
|
||||
+36
@@ -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;
|
||||
|
||||
+71
@@ -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();
|
||||
}
|
||||
}
|
||||
+289
@@ -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.
|
||||
|
||||
+230
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user