Architecture remediation: P2 tier (completeness & polish) #122
@@ -1,18 +1,31 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
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
|
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="value">The message text to redact.</param>
|
||||||
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
|
/// <param name="secrets">The secret values to remove (API key, verify-user password, secured payloads).</param>
|
||||||
public static string Redact(string value, string? apiKey)
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,24 @@ public static class MxGatewayClientCli
|
|||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
"advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token)
|
"advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
.ConfigureAwait(false),
|
.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)
|
"subscribe-bulk" => await SubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
@@ -157,9 +175,15 @@ public static class MxGatewayClientCli
|
|||||||
{
|
{
|
||||||
// Client.Dotnet-028: redact the *effective* key — from --api-key or the
|
// Client.Dotnet-028: redact the *effective* key — from --api-key or the
|
||||||
// --api-key-env environment variable — so an env-var-sourced key echoed
|
// --api-key-env environment variable — so an env-var-sourced key echoed
|
||||||
// in a transport error never reaches stderr unredacted.
|
// in a transport error never reaches stderr unredacted. CLI-04: also
|
||||||
|
// redact MXAccess credentials (AuthenticateUser password, WriteSecured
|
||||||
|
// payloads) that could otherwise be echoed back in a surfaced error.
|
||||||
string? apiKey = TryResolveApiKey(arguments);
|
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"))
|
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||||
{
|
{
|
||||||
@@ -319,6 +343,48 @@ public static class MxGatewayClientCli
|
|||||||
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
|
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)
|
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
|
||||||
{
|
{
|
||||||
var cancellation = new CancellationTokenSource();
|
var cancellation = new CancellationTokenSource();
|
||||||
@@ -475,6 +541,215 @@ public static class MxGatewayClientCli
|
|||||||
cancellationToken);
|
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(
|
private static Task<int> SubscribeBulkAsync(
|
||||||
CliArguments arguments,
|
CliArguments arguments,
|
||||||
IMxGatewayCliClient client,
|
IMxGatewayCliClient client,
|
||||||
@@ -2029,6 +2304,15 @@ public static class MxGatewayClientCli
|
|||||||
or "add-item"
|
or "add-item"
|
||||||
or "advise"
|
or "advise"
|
||||||
or "advise-supervisory"
|
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 "subscribe-bulk"
|
||||||
or "unsubscribe-bulk"
|
or "unsubscribe-bulk"
|
||||||
or "read-bulk"
|
or "read-bulk"
|
||||||
@@ -2092,6 +2376,15 @@ public static class MxGatewayClientCli
|
|||||||
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
|
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 --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 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 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 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]");
|
writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]");
|
||||||
|
|||||||
@@ -129,6 +129,120 @@ public sealed class MxGatewayClientCliTests
|
|||||||
Assert.Equal(string.Empty, error.ToString());
|
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>
|
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
||||||
|
|||||||
@@ -415,6 +415,297 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
Assert.Equal(7, el.Value.Int32Value);
|
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)
|
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
|
||||||
{
|
{
|
||||||
return new MxGatewayClient(transport.Options, transport);
|
return new MxGatewayClient(transport.Options, transport);
|
||||||
|
|||||||
@@ -842,6 +842,525 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
cancellationToken);
|
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>
|
/// <summary>
|
||||||
/// Invokes an MXAccess command on this session.
|
/// Invokes an MXAccess command on this session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+23
-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).
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
`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
|
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
||||||
returned subscription owns cancellation and exposes `Close` for deterministic
|
returned subscription owns cancellation and exposes `Close` for deterministic
|
||||||
goroutine cleanup. Raw protobuf messages remain available through the
|
goroutine cleanup. Raw protobuf messages remain available through the
|
||||||
@@ -151,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
|
supervisory and then pass that user id on the write. Without the supervisory
|
||||||
advise the `userID` on a plain write is ignored.
|
advise the `userID` on a plain write is ignored.
|
||||||
|
|
||||||
The session exposes `Advise`/`UnAdvise` but not supervisory advise, so send it
|
The session exposes a typed `AdviseSupervisory` helper alongside `Advise`/`UnAdvise`:
|
||||||
through the generic command channel:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
_, err := client.Invoke(ctx, &pb.MxCommandRequest{
|
err := session.AdviseSupervisory(ctx, serverHandle, itemHandle)
|
||||||
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.Write(ctx, serverHandle, itemHandle, value, userID)
|
err = session.Write(ctx, serverHandle, itemHandle, value, userID)
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI exposes the same command as `advise-supervisory`, and `write`
|
The CLI exposes the same command as `advise-supervisory`, and `write`
|
||||||
takes `-user-id`.
|
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
|
### Array writes replace the whole array
|
||||||
|
|
||||||
A write to an array attribute **replaces the entire array**; it is not an
|
A write to an array attribute **replaces the entire array**; it is not an
|
||||||
@@ -378,6 +383,8 @@ Every subcommand wired into the CLI. All accept the common flags
|
|||||||
| `unsubscribe-bulk` | Unadvise many item handles in one call. |
|
| `unsubscribe-bulk` | Unadvise many item handles in one call. |
|
||||||
| `read-bulk` | Read snapshots for many item handles in one call. |
|
| `read-bulk` | Read snapshots for many item handles in one call. |
|
||||||
| `write` | Write one value (`-type`, `-value`). |
|
| `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). |
|
| `write-bulk` | Write many values (`-item-handles`, `-values`, counts must match). |
|
||||||
| `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). |
|
| `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). |
|
||||||
| `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). |
|
| `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). |
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
|
||||||
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
"google.golang.org/protobuf/reflect/protoreflect"
|
"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)
|
return runAdvise(ctx, args[1:], stdout, stderr)
|
||||||
case "advise-supervisory":
|
case "advise-supervisory":
|
||||||
return runAdviseSupervisory(ctx, args[1:], stdout, stderr)
|
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":
|
case "subscribe-bulk":
|
||||||
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
|
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
|
||||||
case "unsubscribe-bulk":
|
case "unsubscribe-bulk":
|
||||||
@@ -383,21 +386,90 @@ func runAdviseSupervisory(ctx context.Context, args []string, stdout, stderr io.
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
reply, err := client.Invoke(ctx, &pb.MxCommandRequest{
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
SessionId: *sessionID,
|
reply, err := session.AdviseSupervisoryRaw(ctx, int32(*serverHandle), int32(*itemHandle))
|
||||||
Command: &pb.MxCommand{
|
|
||||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
|
||||||
Payload: &pb.MxCommand_AdviseSupervisory{
|
|
||||||
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
|
|
||||||
ServerHandle: int32(*serverHandle),
|
|
||||||
ItemHandle: int32(*itemHandle),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return writeCommandOutput(stdout, *jsonOutput, "advise-supervisory", options, reply, err)
|
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 {
|
func runSubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError)
|
flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError)
|
||||||
flags.SetOutput(stderr)
|
flags.SetOutput(stderr)
|
||||||
@@ -1295,7 +1367,7 @@ type protojsonMessage interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeUsage(writer io.Writer) {
|
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
|
// 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
|
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
|
||||||
// guard so a write-bulk with unequal item-handles / values counts fails fast
|
// guard so a write-bulk with unequal item-handles / values counts fails fast
|
||||||
// before any dial.
|
// before any dial.
|
||||||
|
|||||||
@@ -3,10 +3,68 @@ package mxgateway
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
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
|
// ErrSlowConsumer is the terminal error sent on the Events/EventsAfter
|
||||||
// (cancel-when-full) path when the buffered results channel overflows because
|
// (cancel-when-full) path when the buffered results channel overflows because
|
||||||
// the consumer fell behind. It is delivered as the final EventResult.Err before
|
// the consumer fell behind. It is delivered as the final EventResult.Err before
|
||||||
|
|||||||
@@ -706,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,
|
// Events streams ordered session events until the server ends the stream,
|
||||||
// context cancellation stops Recv, or a terminal error is sent.
|
// context cancellation stops Recv, or a terminal error is sent.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-11
@@ -153,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
|
item supervisory and then pass that user id on the write. Without the
|
||||||
supervisory advise the `user_id` on a plain write is ignored.
|
supervisory advise the `user_id` on a plain write is ignored.
|
||||||
|
|
||||||
The session exposes `advise`/`unadvise` but not supervisory advise, so send it
|
The session exposes a typed `advise_supervisory` helper alongside
|
||||||
through the generic command channel:
|
`advise`/`unadvise`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
await session.invoke(
|
await session.advise_supervisory(server_handle, item_handle)
|
||||||
pb.MxCommand(
|
|
||||||
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
|
||||||
advise_supervisory=pb.AdviseSupervisoryCommand(
|
|
||||||
server_handle=server_handle,
|
|
||||||
item_handle=item_handle,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.write(server_handle, item_handle, value, user_id=user_id)
|
await session.write(server_handle, item_handle, value, user_id=user_id)
|
||||||
```
|
```
|
||||||
@@ -173,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` /
|
The CLI exposes the same command as `advise-supervisory`, and `write` /
|
||||||
`write2` take `--user-id`.
|
`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
|
### Array writes replace the whole array
|
||||||
|
|
||||||
A write to an array attribute **replaces the entire array**; it is not an
|
A write to an array attribute **replaces the entire array**; it is not an
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import AsyncIterator, Sequence
|
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 .events import ReplayGap
|
||||||
from .generated import mxaccess_gateway_pb2 as pb
|
from .generated import mxaccess_gateway_pb2 as pb
|
||||||
from .values import MxValueInput, to_mx_value
|
from .values import MxValueInput, to_mx_value
|
||||||
@@ -569,6 +570,249 @@ class Session:
|
|||||||
correlation_id=correlation_id,
|
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(
|
def stream_events(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -631,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)")
|
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
|
from .client import GatewayClient # noqa: E402
|
||||||
|
|||||||
@@ -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")
|
@main.command("subscribe-bulk")
|
||||||
@gateway_options
|
@gateway_options
|
||||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
@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 def _advise_supervisory(**kwargs: Any) -> dict[str, Any]:
|
||||||
async with await _connect(kwargs) as client:
|
async with await _connect(kwargs) as client:
|
||||||
session = _session(client, kwargs["session_id"])
|
session = _session(client, kwargs["session_id"])
|
||||||
await session.invoke(
|
await session.advise_supervisory(
|
||||||
pb.MxCommand(
|
kwargs["server_handle"],
|
||||||
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
kwargs["item_handle"],
|
||||||
advise_supervisory=pb.AdviseSupervisoryCommand(
|
|
||||||
server_handle=kwargs["server_handle"],
|
|
||||||
item_handle=kwargs["item_handle"],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
correlation_id=kwargs["correlation_id"],
|
correlation_id=kwargs["correlation_id"],
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
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 def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||||
async with await _connect(kwargs) as client:
|
async with await _connect(kwargs) as client:
|
||||||
session = _session(client, kwargs["session_id"])
|
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 result.exit_code == 0
|
||||||
assert "--parent-gobject-id" in result.output
|
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,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
|
||||||
+22
-12
@@ -192,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
|
item supervisory and then pass that user id on the write. Without the
|
||||||
supervisory advise the `user_id` on a plain write is ignored.
|
supervisory advise the `user_id` on a plain write is ignored.
|
||||||
|
|
||||||
The session exposes `advise`/`un_advise` but not supervisory advise, so send it
|
The session exposes a typed `advise_supervisory` helper alongside
|
||||||
through the generic command channel:
|
`advise`/`un_advise`:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
session
|
session.advise_supervisory(server_handle, item_handle).await?;
|
||||||
.invoke(
|
|
||||||
MxCommandKind::AdviseSupervisory,
|
|
||||||
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
|
|
||||||
server_handle,
|
|
||||||
item_handle,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
session.write(server_handle, item_handle, value, user_id).await?;
|
session.write(server_handle, item_handle, value, user_id).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI exposes the same command as `advise-supervisory`, and `write` /
|
The CLI exposes the same command as `advise-supervisory`, and `write` /
|
||||||
`write2` take `--user-id`.
|
`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
|
### Array writes replace the whole array
|
||||||
|
|
||||||
A write to an array attribute **replaces the entire array**; it is not an
|
A write to an array attribute **replaces the entire array**; it is not an
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ use serde_json::Value;
|
|||||||
use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode};
|
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::galaxy_repository::v1::DeployEvent;
|
||||||
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||||
alarm_feed_message, AcknowledgeAlarmRequest, AdviseSupervisoryCommand, AlarmFeedMessage,
|
alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
|
||||||
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily,
|
MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxValue as ProtoMxValue,
|
||||||
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamAlarmsRequest,
|
OpenSessionRequest, PingCommand, StreamAlarmsRequest, StreamEventsRequest, Write2BulkEntry,
|
||||||
StreamEventsRequest, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry,
|
WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
|
||||||
WriteSecuredBulkEntry,
|
|
||||||
};
|
};
|
||||||
use zb_mom_ww_mxgateway_client::{
|
use zb_mom_ww_mxgateway_client::{
|
||||||
next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient,
|
next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient,
|
||||||
@@ -118,6 +117,67 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
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 {
|
SubscribeBulk {
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
connection: ConnectionArgs,
|
connection: ConnectionArgs,
|
||||||
@@ -669,18 +729,69 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
|||||||
} => {
|
} => {
|
||||||
let session = session_for(connection, session_id).await?;
|
let session = session_for(connection, session_id).await?;
|
||||||
session
|
session
|
||||||
.invoke(
|
.advise_supervisory(server_handle, item_handle)
|
||||||
MxCommandKind::AdviseSupervisory,
|
|
||||||
zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::AdviseSupervisory(
|
|
||||||
AdviseSupervisoryCommand {
|
|
||||||
server_handle,
|
|
||||||
item_handle,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
print_ok("advise-supervisory", json);
|
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 {
|
Command::SubscribeBulk {
|
||||||
connection,
|
connection,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -2489,6 +2600,59 @@ mod tests {
|
|||||||
assert_eq!(value["workerProtocolVersion"], 1);
|
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]
|
#[test]
|
||||||
fn parses_stream_alarms_command() {
|
fn parses_stream_alarms_command() {
|
||||||
let parsed = Cli::try_parse_from([
|
let parsed = Cli::try_parse_from([
|
||||||
|
|||||||
+357
-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::Payload;
|
||||||
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
|
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
|
||||||
use crate::generated::mxaccess_gateway::v1::{
|
use crate::generated::mxaccess_gateway::v1::{
|
||||||
AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand,
|
ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
|
||||||
BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply,
|
AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
|
||||||
MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue,
|
AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand,
|
||||||
OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand,
|
MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement,
|
||||||
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand,
|
MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand,
|
||||||
UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry,
|
RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand,
|
||||||
Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand,
|
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
|
||||||
WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry,
|
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;
|
const MAX_BULK_ITEMS: usize = 1_000;
|
||||||
|
|
||||||
@@ -129,6 +132,25 @@ impl Session {
|
|||||||
register_server_handle(&reply)
|
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
|
/// Run MXAccess `AddItem` against `server_handle` and return the
|
||||||
/// assigned `ItemHandle`.
|
/// assigned `ItemHandle`.
|
||||||
///
|
///
|
||||||
@@ -230,6 +252,133 @@ impl Session {
|
|||||||
Ok(())
|
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
|
/// Bulk variant of [`Session::add_item`]. Each tag address yields one
|
||||||
/// `SubscribeResult` in the returned vector.
|
/// `SubscribeResult` in the returned vector.
|
||||||
///
|
///
|
||||||
@@ -628,6 +777,142 @@ impl Session {
|
|||||||
Ok(())
|
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.
|
/// Open the per-session event stream from the beginning.
|
||||||
///
|
///
|
||||||
/// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
|
/// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
|
||||||
@@ -769,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 {
|
enum BulkReplyKind {
|
||||||
AddItem,
|
AddItem,
|
||||||
AdviseItem,
|
AdviseItem,
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_value::Kind;
|
|||||||
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||||
alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot,
|
alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot,
|
||||||
AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind,
|
AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind,
|
||||||
BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult,
|
AuthenticateUserCommand, AuthenticateUserReply, BulkReadReply, BulkReadResult,
|
||||||
CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent,
|
BulkSubscribeReply, BulkWriteReply, BulkWriteResult, CloseSessionReply, CloseSessionRequest,
|
||||||
MxEventFamily, MxSparseArray, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource,
|
MxCommandKind, MxCommandReply, MxDataType, MxEvent, MxEventFamily, MxSparseArray,
|
||||||
MxValue, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
|
MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue,
|
||||||
|
OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
|
||||||
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, ReplayGap, SessionState,
|
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, ReplayGap, SessionState,
|
||||||
StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry,
|
StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry,
|
||||||
WriteCommand, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
|
WriteCommand, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
|
||||||
@@ -624,6 +625,133 @@ async fn write_secured2_bulk_round_trips_through_the_fake_gateway() {
|
|||||||
assert_eq!(*last_command, Some(MxCommandKind::WriteSecured2Bulk as i32));
|
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]
|
#[tokio::test]
|
||||||
async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
|
async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
|
||||||
let state = Arc::new(FakeState::default());
|
let state = Arc::new(FakeState::default());
|
||||||
@@ -732,6 +860,10 @@ struct FakeState {
|
|||||||
/// Captures the last `WriteCommand` payload received, populated when the
|
/// Captures the last `WriteCommand` payload received, populated when the
|
||||||
/// `WriteOk` override is active. Used by `write_array_elements` e2e test.
|
/// `WriteOk` override is active. Used by `write_array_elements` e2e test.
|
||||||
last_write_command: Mutex<Option<WriteCommand>>,
|
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>,
|
stream_dropped: Arc<AtomicBool>,
|
||||||
/// Optional per-test override that pins the fake's `Invoke` handler to
|
/// Optional per-test override that pins the fake's `Invoke` handler to
|
||||||
/// a specific reply shape (or `Err(Status)`). The default of `None`
|
/// a specific reply shape (or `Err(Status)`). The default of `None`
|
||||||
@@ -765,6 +897,12 @@ enum InvokeOverride {
|
|||||||
/// and capture the decoded `WriteCommand` in
|
/// and capture the decoded `WriteCommand` in
|
||||||
/// `FakeState::last_write_command` for inspection.
|
/// `FakeState::last_write_command` for inspection.
|
||||||
WriteOk,
|
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)]
|
#[derive(Clone)]
|
||||||
@@ -846,6 +984,27 @@ impl MxAccessGateway for FakeGateway {
|
|||||||
..MxCommandReply::default()
|
..MxCommandReply::default()
|
||||||
})),
|
})),
|
||||||
InvokeOverride::Unavailable(message) => Err(Status::unavailable(message)),
|
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 => {
|
InvokeOverride::WriteOk => {
|
||||||
// Extract and capture the WriteCommand payload so the test
|
// Extract and capture the WriteCommand payload so the test
|
||||||
// can assert on server_handle, item_handle, user_id, and value.
|
// can assert on server_handle, item_handle, user_id, and value.
|
||||||
@@ -977,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 {
|
Ok(Response::new(MxCommandReply {
|
||||||
session_id: request.session_id,
|
session_id: request.session_id,
|
||||||
correlation_id: "fake-correlation".to_owned(),
|
correlation_id: "fake-correlation".to_owned(),
|
||||||
|
|||||||
@@ -104,6 +104,29 @@ All languages should expose the same core concepts, using idiomatic naming:
|
|||||||
The gateway session id and MXAccess handles must remain visible. The library may
|
The gateway session id and MXAccess handles must remain visible. The library may
|
||||||
offer helper methods, but it must not invent alternate handle semantics.
|
offer helper methods, but it must not invent alternate handle semantics.
|
||||||
|
|
||||||
|
## Typed Command Parity
|
||||||
|
|
||||||
|
Every command kind in the wire contract has a typed single-item session helper,
|
||||||
|
not just a raw-`Invoke` escape hatch (CLI-04). Beyond the register/add/advise/
|
||||||
|
remove/write family, the parity-critical single-item helpers are:
|
||||||
|
`AdviseSupervisory`, `WriteSecured` / `WriteSecured2`, `AuthenticateUser`,
|
||||||
|
`ArchestrAUserToId`, `AddBufferedItem`, `SetBufferedUpdateInterval`, `Suspend`,
|
||||||
|
`Activate`, and `Unregister`. Each is a thin wrapper over the same raw-command
|
||||||
|
machinery the bulk helpers use — it adds no wire surface — and runs the same
|
||||||
|
MXAccess-level reply validation (HRESULT `< 0` + per-item `MxStatusProxy`) as the
|
||||||
|
rest of the client. **MXAccess parity is preserved exactly**: e.g. `WriteSecured`
|
||||||
|
failing before a prior `AuthenticateUser` + `AdviseSupervisory` surfaces the
|
||||||
|
native failure unchanged — the helper does not pre-validate or reorder it.
|
||||||
|
|
||||||
|
**Credential handling:** `AuthenticateUser` credentials and `WriteSecured`
|
||||||
|
secured payloads route through each client's secret-redaction seam so they never
|
||||||
|
reach logs, exception text, or `ToString`/`Debug`/`Display` — the value is carried
|
||||||
|
only on the wire. Each client's test suite asserts a distinctive credential is
|
||||||
|
absent from any surfaced error.
|
||||||
|
|
||||||
|
Shipped for .NET / Go / Rust / Python; the Java client's typed parity helpers are
|
||||||
|
batched to the windows build host (no local JRE on the primary dev box).
|
||||||
|
|
||||||
## Shared API Shape
|
## Shared API Shape
|
||||||
|
|
||||||
Each language should support this conceptual API:
|
Each language should support this conceptual API:
|
||||||
|
|||||||
Reference in New Issue
Block a user