37cb3b0df8
All five client CLIs now share one credential contract for `authenticate-user`: flags `--password` / `--password-env` (Go: `-password` / `-password-env`) with default env `MXGATEWAY_VERIFY_PASSWORD`, resolution flag-then-env, and a resolved credential that is missing *or empty* is a usage error naming the flag and the variable. The value is never echoed and never reaches the wire. Go and Java previously sent an empty credential when the variable was unset, turning a misconfigured environment into a real MXAccess authentication attempt. Go now returns the guard error before dialing; Java throws a picocli ParameterException instead of falling back to "". Python's `--password-env` gained the canonical default and its UsageError names the resolved variable. Rust treats an empty flag or env value as missing, with the resolution extracted into a testable `resolve_verify_user_password`. .NET adopts the canonical flags and keeps `--verify-user-password`, `--verify-user-password-env`, and MXGATEWAY_VERIFY_USER_PASSWORD as deprecated aliases for one release. Docs same commit: CrossLanguageSmokeMatrix.md gains the credential contract and the per-CLI subcommand-coverage table (the documented-not-fixed half of the finding); all five READMEs name the canonical variable and the fail-fast rule, and the .NET README carries the deprecation note. Tracking flipped to Done in both remediation registers with a change-log row. No .proto changed; no generated code regenerated.
1676 lines
64 KiB
C#
1676 lines
64 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.MxGateway.Client.Cli;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|
|
|
/// <summary>Tests for the CLI command interface.</summary>
|
|
public sealed class MxGatewayClientCliTests
|
|
{
|
|
/// <summary>Verifies that the version command prints compiled protocol versions.</summary>
|
|
[Fact]
|
|
public void Run_Version_PrintsCompiledProtocolVersions()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("gateway-protocol=3", output.ToString());
|
|
Assert.Contains("worker-protocol=1", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("\"gatewayProtocolVersion\":3", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.Write,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"write",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--session-id",
|
|
"session-fixture",
|
|
"--server-handle",
|
|
"12",
|
|
"--item-handle",
|
|
"34",
|
|
"--type",
|
|
"int32",
|
|
"--value",
|
|
"123",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
|
|
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
|
|
Assert.Equal(123, request.Command.Write.Value.Int32Value);
|
|
Assert.Contains("MX_COMMAND_KIND_WRITE", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>advise-supervisory</c> was present in the command
|
|
/// dispatch table but absent from <see cref="MxGatewayClientCli"/>'s
|
|
/// <c>IsKnownGatewayCommand</c> guard, so the guard intercepted it first and
|
|
/// returned exit code 2 "Unknown command" before dispatch could run. This
|
|
/// test asserts the command is recognized (exit ≠ 2, stderr contains no
|
|
/// "Unknown command") and reaches the dispatch handler (exit 0, reply written
|
|
/// to stdout).
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_AdviseSupervisory_IsRecognizedAndReachesDispatch()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.AdviseSupervisory,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"advise-supervisory",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--session-id",
|
|
"session-fixture",
|
|
"--server-handle",
|
|
"12",
|
|
"--item-handle",
|
|
"34",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.DoesNotContain("Unknown command", error.ToString());
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("MX_COMMAND_KIND_ADVISE_SUPERVISORY", output.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",
|
|
"--password", password,
|
|
],
|
|
output,
|
|
error,
|
|
_ => throw new InvalidOperationException($"boom {password}"));
|
|
|
|
Assert.Equal(1, exitCode);
|
|
Assert.DoesNotContain(password, error.ToString());
|
|
Assert.Contains("[redacted]", error.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// CLI-45: <c>--password</c> is the primary credential flag, matching the other
|
|
/// four CLIs. The credential reaches the wire but never stdout/stderr.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_AuthenticateUser_AcceptsCanonicalPasswordFlag()
|
|
{
|
|
const string password = "canonical-flag-credential";
|
|
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 = 11 },
|
|
});
|
|
|
|
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",
|
|
"--password", password,
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
|
|
Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
|
|
Assert.DoesNotContain(password, output.ToString(), StringComparison.Ordinal);
|
|
Assert.DoesNotContain(password, error.ToString(), StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CLI-45: the credential is read from the environment variable named by
|
|
/// <c>--password-env</c>, whose default is the canonical
|
|
/// <c>MXGATEWAY_VERIFY_PASSWORD</c> shared by all five CLIs.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[InlineData(null, "MXGATEWAY_VERIFY_PASSWORD")]
|
|
[InlineData("MXGW_TEST_CLI45_ENV", "MXGW_TEST_CLI45_ENV")]
|
|
public async Task RunAsync_AuthenticateUser_ReadsCredentialFromNamedEnvironmentVariable(
|
|
string? passwordEnvArgument,
|
|
string environmentName)
|
|
{
|
|
const string password = "env-sourced-credential";
|
|
using EnvironmentVariableScope scope = new(environmentName, password);
|
|
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 = 12 },
|
|
});
|
|
|
|
List<string> args =
|
|
[
|
|
"authenticate-user",
|
|
"--endpoint", "http://localhost:5000",
|
|
"--api-key", "test-api-key",
|
|
"--session-id", "session-fixture",
|
|
"--server-handle", "12",
|
|
"--verify-user", "operator",
|
|
"--json",
|
|
];
|
|
if (passwordEnvArgument is not null)
|
|
{
|
|
args.Add("--password-env");
|
|
args.Add(passwordEnvArgument);
|
|
}
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync([.. args], output, error, _ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
|
|
Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
|
|
Assert.DoesNotContain(password, output.ToString(), StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CLI-45: the pre-rename names stay usable for one release — the deprecated
|
|
/// <c>--verify-user-password</c> flag and the deprecated
|
|
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c> environment variable both still resolve.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_AuthenticateUser_HonoursDeprecatedAliases()
|
|
{
|
|
using var flagOutput = new StringWriter();
|
|
using var flagError = new StringWriter();
|
|
FakeCliClient flagClient = new();
|
|
flagClient.InvokeReplies.Enqueue(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.AuthenticateUser,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
AuthenticateUser = new AuthenticateUserReply { UserId = 13 },
|
|
});
|
|
|
|
int flagExitCode = 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", "legacy-flag-credential",
|
|
"--json",
|
|
],
|
|
flagOutput,
|
|
flagError,
|
|
_ => flagClient);
|
|
|
|
Assert.Equal(0, flagExitCode);
|
|
Assert.Equal(
|
|
"legacy-flag-credential",
|
|
Assert.Single(flagClient.InvokeRequests).Command.AuthenticateUser.VerifyUserPassword);
|
|
|
|
using EnvironmentVariableScope canonical = new("MXGATEWAY_VERIFY_PASSWORD", null);
|
|
using EnvironmentVariableScope legacy = new("MXGATEWAY_VERIFY_USER_PASSWORD", "legacy-env-credential");
|
|
using var envOutput = new StringWriter();
|
|
using var envError = new StringWriter();
|
|
FakeCliClient envClient = new();
|
|
envClient.InvokeReplies.Enqueue(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.AuthenticateUser,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
AuthenticateUser = new AuthenticateUserReply { UserId = 14 },
|
|
});
|
|
|
|
int envExitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"authenticate-user",
|
|
"--endpoint", "http://localhost:5000",
|
|
"--api-key", "test-api-key",
|
|
"--session-id", "session-fixture",
|
|
"--server-handle", "12",
|
|
"--verify-user", "operator",
|
|
"--json",
|
|
],
|
|
envOutput,
|
|
envError,
|
|
_ => envClient);
|
|
|
|
Assert.Equal(0, envExitCode);
|
|
Assert.Equal(
|
|
"legacy-env-credential",
|
|
Assert.Single(envClient.InvokeRequests).Command.AuthenticateUser.VerifyUserPassword);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CLI-45: a missing or empty credential fails fast before the invoke — the CLI
|
|
/// never sends a fabricated empty password to the wire. The error names the flag
|
|
/// and the environment variable, never a value.
|
|
/// </summary>
|
|
/// <param name="explicitEmptyFlag">Whether to pass an explicit empty --password.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public async Task RunAsync_AuthenticateUser_FailsFastOnMissingOrEmptyCredential(bool explicitEmptyFlag)
|
|
{
|
|
using EnvironmentVariableScope canonical = new("MXGATEWAY_VERIFY_PASSWORD", explicitEmptyFlag ? string.Empty : null);
|
|
using EnvironmentVariableScope legacy = new("MXGATEWAY_VERIFY_USER_PASSWORD", null);
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
|
|
List<string> args =
|
|
[
|
|
"authenticate-user",
|
|
"--endpoint", "http://localhost:5000",
|
|
"--api-key", "test-api-key",
|
|
"--session-id", "session-fixture",
|
|
"--server-handle", "12",
|
|
"--verify-user", "operator",
|
|
];
|
|
if (explicitEmptyFlag)
|
|
{
|
|
args.Add("--password");
|
|
args.Add(string.Empty);
|
|
}
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync([.. args], output, error, _ => fakeClient);
|
|
|
|
Assert.Equal(1, exitCode);
|
|
Assert.Empty(fakeClient.InvokeRequests);
|
|
Assert.Contains("--password", error.ToString(), StringComparison.Ordinal);
|
|
Assert.Contains("MXGATEWAY_VERIFY_PASSWORD", error.ToString(), StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets an environment variable for the duration of a test and restores the
|
|
/// previous value on dispose, so credential-resolution tests do not depend on
|
|
/// (or leak into) the ambient environment.
|
|
/// </summary>
|
|
private sealed class EnvironmentVariableScope : IDisposable
|
|
{
|
|
private readonly string _name;
|
|
private readonly string? _original;
|
|
|
|
public EnvironmentVariableScope(string name, string? value)
|
|
{
|
|
_name = name;
|
|
_original = Environment.GetEnvironmentVariable(name);
|
|
Environment.SetEnvironmentVariable(name, value);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Environment.SetEnvironmentVariable(_name, _original);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"open-session",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"secret-api-key",
|
|
],
|
|
output,
|
|
error,
|
|
_ => throw new InvalidOperationException("boom secret-api-key"));
|
|
|
|
Assert.Equal(1, exitCode);
|
|
Assert.DoesNotContain("secret-api-key", error.ToString());
|
|
Assert.Contains("[redacted]", error.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the API key is sourced from the env var
|
|
/// (<c>--api-key-env</c> path, no <c>--api-key</c> flag), the error-redaction
|
|
/// catch block must still resolve and redact the effective key. Regression
|
|
/// guard for the catch block reverting to <c>GetOptional("api-key")</c> only,
|
|
/// which is null on the env-var path and leaves the key unredacted.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable()
|
|
{
|
|
const string envName = "MXGATEWAY_TEST_API_KEY_028";
|
|
const string secret = "env-sourced-secret-key";
|
|
string? previousKey = Environment.GetEnvironmentVariable(envName);
|
|
Environment.SetEnvironmentVariable(envName, secret);
|
|
|
|
try
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"open-session",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key-env",
|
|
envName,
|
|
],
|
|
output,
|
|
error,
|
|
_ => throw new InvalidOperationException($"boom {secret}"));
|
|
|
|
Assert.Equal(1, exitCode);
|
|
Assert.DoesNotContain(secret, error.ToString());
|
|
Assert.Contains("[redacted]", error.ToString());
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(envName, previousKey);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.Events.Add(new MxEvent
|
|
{
|
|
SessionId = "session-fixture",
|
|
Family = MxEventFamily.OnDataChange,
|
|
WorkerSequence = 1,
|
|
});
|
|
fakeClient.Events.Add(new MxEvent
|
|
{
|
|
SessionId = "session-fixture",
|
|
Family = MxEventFamily.OnWriteComplete,
|
|
WorkerSequence = 2,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"stream-events",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--session-id",
|
|
"session-fixture",
|
|
"--max-events",
|
|
"1",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("workerSequence", output.ToString());
|
|
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
|
}
|
|
|
|
|
|
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
|
{
|
|
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "Tank01.Level.HiHi" },
|
|
});
|
|
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage { SnapshotComplete = true });
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"stream-alarms",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--filter-prefix",
|
|
"Tank01",
|
|
"--max-events",
|
|
"1",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
StreamAlarmsRequest request = Assert.Single(fakeClient.StreamAlarmsRequests);
|
|
Assert.Equal("Tank01", request.AlarmFilterPrefix);
|
|
string text = output.ToString();
|
|
Assert.Contains("active-alarm", text);
|
|
Assert.Contains("Tank01.Level.HiHi", text);
|
|
Assert.DoesNotContain("snapshot-complete", text);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
|
|
{
|
|
CorrelationId = "ack-fixture",
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
Hresult = 0,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"acknowledge-alarm",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--reference",
|
|
"Tank01.Level.HiHi",
|
|
"--comment",
|
|
"ack from cli",
|
|
"--operator",
|
|
"operator1",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
AcknowledgeAlarmRequest request = Assert.Single(fakeClient.AcknowledgeAlarmRequests);
|
|
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
|
Assert.Equal("ack from cli", request.Comment);
|
|
Assert.Equal("operator1", request.OperatorUser);
|
|
Assert.Contains("ack-fixture", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new()
|
|
{
|
|
InvokeFailure = new InvalidOperationException("register failed"),
|
|
};
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"smoke",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--item",
|
|
"Area001.Pump001.Speed",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(1, exitCode);
|
|
CloseSessionRequest closeRequest = Assert.Single(fakeClient.CloseSessionRequests);
|
|
Assert.Equal("session-fixture", closeRequest.SessionId);
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new()
|
|
{
|
|
GalaxyTestConnectionReply = new TestConnectionReply { Ok = true },
|
|
};
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-test-connection",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Single(fakeClient.GalaxyTestConnectionRequests);
|
|
Assert.Contains("\"ok\": true", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
{
|
|
NextPageToken = "7:1",
|
|
TotalObjectCount = 2,
|
|
Objects =
|
|
{
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 7,
|
|
TagName = "DelmiaReceiver_001",
|
|
ContainedName = "DelmiaReceiver",
|
|
ParentGobjectId = 1,
|
|
Attributes =
|
|
{
|
|
new GalaxyAttribute
|
|
{
|
|
AttributeName = "DownloadPath",
|
|
FullTagReference = "DelmiaReceiver_001.DownloadPath",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
{
|
|
TotalObjectCount = 2,
|
|
Objects =
|
|
{
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 8,
|
|
TagName = "DelmiaReceiver_002",
|
|
ContainedName = "DelmiaReceiver",
|
|
ParentGobjectId = 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-discover",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Equal(2, fakeClient.GalaxyDiscoverHierarchyRequests.Count);
|
|
Assert.Equal(5000, fakeClient.GalaxyDiscoverHierarchyRequests[0].PageSize);
|
|
Assert.Equal("", fakeClient.GalaxyDiscoverHierarchyRequests[0].PageToken);
|
|
Assert.Equal("7:1", fakeClient.GalaxyDiscoverHierarchyRequests[1].PageToken);
|
|
string text = output.ToString();
|
|
Assert.Contains("objects=2", text);
|
|
Assert.Contains("DelmiaReceiver_001", text);
|
|
Assert.Contains("DelmiaReceiver_002", text);
|
|
Assert.Contains("attributes=1", text);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies galaxy-browse walks root objects and eagerly expands one further
|
|
/// level when --depth 1 is passed, printing an indented tree.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyBrowse_TextTreeExpandsToDepth()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
// Root level (parent 0): one area with a child hint.
|
|
fakeClient.GalaxyBrowseChildrenReplies[0] = new Queue<BrowseChildrenReply>(
|
|
[
|
|
new BrowseChildrenReply
|
|
{
|
|
Children = { new GalaxyObject { GobjectId = 10, TagName = "Area_001", BrowseName = "Area" } },
|
|
ChildHasChildren = { true },
|
|
},
|
|
]);
|
|
// Children of gobject 10.
|
|
fakeClient.GalaxyBrowseChildrenReplies[10] = new Queue<BrowseChildrenReply>(
|
|
[
|
|
new BrowseChildrenReply
|
|
{
|
|
Children = { new GalaxyObject { GobjectId = 20, TagName = "Tank_001", BrowseName = "Tank" } },
|
|
},
|
|
]);
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-browse",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--depth",
|
|
"1",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
string text = output.ToString();
|
|
Assert.Contains("Area_001", text);
|
|
Assert.Contains("Tank_001", text);
|
|
// Children are indented beneath their parent (two-space indent per level).
|
|
Assert.Matches(@"\n \d+\tTank_001", text);
|
|
// Root fetched with the parent oneof unset; child fetch used parent 10.
|
|
Assert.Contains(
|
|
fakeClient.GalaxyBrowseChildrenRequests,
|
|
request => request.ParentCase == BrowseChildrenRequest.ParentOneofCase.ParentGobjectId
|
|
&& request.ParentGobjectId == 10);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies galaxy-browse --json emits a nested JSON document and forwards
|
|
/// the filter flags onto the BrowseChildren request.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyBrowse_JsonForwardsFilters()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.GalaxyBrowseChildrenReplies[0] = new Queue<BrowseChildrenReply>(
|
|
[
|
|
new BrowseChildrenReply
|
|
{
|
|
Children = { new GalaxyObject { GobjectId = 10, TagName = "Area_001", BrowseName = "Area" } },
|
|
},
|
|
]);
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-browse",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--tag-name-glob",
|
|
"Area*",
|
|
"--alarm-bearing-only",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(output.ToString());
|
|
Assert.Equal("galaxy-browse", document.RootElement.GetProperty("command").GetString());
|
|
Assert.True(document.RootElement.GetProperty("nodes").GetArrayLength() >= 1);
|
|
BrowseChildrenRequest request = Assert.Single(fakeClient.GalaxyBrowseChildrenRequests);
|
|
Assert.Equal("Area*", request.TagNameGlob);
|
|
Assert.True(request.AlarmBearingOnly);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies galaxy-browse --parent fetches exactly one level of children for
|
|
/// the supplied gobject id via a parent-scoped BrowseChildren request.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyBrowse_ParentFetchesSingleLevel()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.GalaxyBrowseChildrenReplies[10] = new Queue<BrowseChildrenReply>(
|
|
[
|
|
new BrowseChildrenReply
|
|
{
|
|
Children = { new GalaxyObject { GobjectId = 20, TagName = "Tank_001", BrowseName = "Tank" } },
|
|
},
|
|
]);
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-browse",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--parent",
|
|
"10",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("Tank_001", output.ToString());
|
|
BrowseChildrenRequest request = Assert.Single(fakeClient.GalaxyBrowseChildrenRequests);
|
|
Assert.Equal(BrowseChildrenRequest.ParentOneofCase.ParentGobjectId, request.ParentCase);
|
|
Assert.Equal(10, request.ParentGobjectId);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
DateTime deploy = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = 1,
|
|
ObservedAt = Timestamp.FromDateTime(deploy),
|
|
TimeOfLastDeploy = Timestamp.FromDateTime(deploy),
|
|
TimeOfLastDeployPresent = true,
|
|
ObjectCount = 5,
|
|
AttributeCount = 17,
|
|
});
|
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = 2,
|
|
ObservedAt = Timestamp.FromDateTime(deploy.AddSeconds(30)),
|
|
TimeOfLastDeploy = Timestamp.FromDateTime(deploy.AddSeconds(30)),
|
|
TimeOfLastDeployPresent = true,
|
|
ObjectCount = 6,
|
|
AttributeCount = 18,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-watch",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--last-seen-deploy-time",
|
|
"2026-04-28T14:00:00Z",
|
|
"--max-events",
|
|
"2",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
WatchDeployEventsRequest request = Assert.Single(fakeClient.GalaxyWatchDeployEventsRequests);
|
|
Assert.NotNull(request.LastSeenDeployTime);
|
|
string text = output.ToString();
|
|
Assert.Contains("sequence=1", text);
|
|
Assert.Contains("sequence=2", text);
|
|
Assert.Contains("objects=5", text);
|
|
Assert.Contains("attributes=18", text);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = 42,
|
|
ObjectCount = 99,
|
|
AttributeCount = 1024,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-watch",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--max-events",
|
|
"1",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
string text = output.ToString();
|
|
Assert.Contains("\"sequence\": \"42\"", text);
|
|
Assert.Contains("\"objectCount\": 99", text);
|
|
}
|
|
|
|
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
using var input = new StringReader("version --json\n");
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
["batch"],
|
|
output,
|
|
error,
|
|
clientFactory: null,
|
|
standardInput: input);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
string text = output.ToString();
|
|
Assert.Contains("\"gatewayProtocolVersion\":3", text);
|
|
Assert.Contains("__MXGW_BATCH_EOR__", text);
|
|
// The EOR marker must come after the JSON output.
|
|
int jsonIndex = text.IndexOf("\"gatewayProtocolVersion\"", StringComparison.Ordinal);
|
|
int eorIndex = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
|
Assert.True(jsonIndex >= 0 && eorIndex > jsonIndex);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
// Unknown command should produce an error on the captured error stream,
|
|
// which batch mode re-emits to stdout inside the same delimited block.
|
|
using var input = new StringReader("nope-not-a-command\nversion\n");
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
["batch"],
|
|
output,
|
|
error,
|
|
clientFactory: null,
|
|
standardInput: input);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
string text = output.ToString();
|
|
// Two records → two EOR markers.
|
|
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
|
int secondEor = text.IndexOf(
|
|
"__MXGW_BATCH_EOR__",
|
|
firstEor + 1,
|
|
StringComparison.Ordinal);
|
|
Assert.True(firstEor > 0);
|
|
Assert.True(secondEor > firstEor);
|
|
// The unknown-command error message must be on stdout (not on stderr).
|
|
Assert.Contains("nope-not-a-command", text);
|
|
Assert.DoesNotContain("nope-not-a-command", error.ToString());
|
|
// The follow-up `version` line should still succeed.
|
|
Assert.Contains("gateway-protocol=", text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The README CLI examples for the alarm subcommands at
|
|
/// `clients/dotnet/README.md` must drive cleanly through the production
|
|
/// CLI argument parser. The previous text used non-existent flags
|
|
/// (`--session-id`, `--max-messages`, `--alarm-reference`) that would
|
|
/// fail with "Unknown command" / "Missing required option --reference".
|
|
/// Each documented example is extracted from the README, parsed via the
|
|
/// production <see cref="MxGatewayClientCli.RunAsync"/>, and asserted
|
|
/// against exit code 0.
|
|
/// </summary>
|
|
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[InlineData("stream-alarms")]
|
|
[InlineData("acknowledge-alarm")]
|
|
public async Task RunAsync_ReadmeExamples_ForAlarmCommands_ParseSuccessfully(string command)
|
|
{
|
|
string readme = LocateClientReadme();
|
|
string[] commandLine = ExtractReadmeCommandLine(readme, command);
|
|
// The documented examples do not include --api-key (the README assumes
|
|
// the env var path documented elsewhere). Inject an API key via the
|
|
// standard env var so CreateOptions succeeds and the parser fully
|
|
// exercises the documented flag shape.
|
|
string? previousKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
|
|
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", "test-api-key");
|
|
|
|
try
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
|
{
|
|
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "fixture" },
|
|
});
|
|
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
|
|
{
|
|
CorrelationId = "ack-fixture",
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
commandLine,
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.True(
|
|
exitCode == 0,
|
|
$"README example for '{command}' exited {exitCode}; stderr=<<{error}>>");
|
|
Assert.DoesNotContain("Unknown command", error.ToString());
|
|
Assert.DoesNotContain("Missing required option", error.ToString());
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", previousKey);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// `BenchReadBulkAsync` previously fell back to
|
|
/// <c>reply.ReturnValue.Int32Value</c> when the register reply had no
|
|
/// typed <c>Register</c> payload, silently driving the rest of the bench
|
|
/// against a zero server handle. The fix must fail loudly with a
|
|
/// descriptive <see cref="MxGatewayException"/>.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
// Successful protocol + MX status but no typed `Register` payload.
|
|
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.Register,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"bench-read-bulk",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--duration-seconds",
|
|
"1",
|
|
"--warmup-seconds",
|
|
"0",
|
|
"--bulk-size",
|
|
"1",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(1, exitCode);
|
|
// Descriptive message that names the missing typed payload.
|
|
string err = error.ToString();
|
|
Assert.Contains("Register", err);
|
|
// The bench must not produce any aggregate stats JSON.
|
|
Assert.DoesNotContain("bench-read-bulk", output.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// The steady-state loop in `BenchReadBulkAsync` had a
|
|
/// bare `catch { failedCalls++; continue; }` that swallowed
|
|
/// <see cref="OperationCanceledException"/>, so token-driven cancellation
|
|
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
|
|
/// the bench must exit promptly when the supplied token cancels.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
int invokeCount = 0;
|
|
FakeCliClient fakeClient = new()
|
|
{
|
|
InvokeHandler = (request, ct) =>
|
|
{
|
|
int n = Interlocked.Increment(ref invokeCount);
|
|
|
|
// Reply 1 = Register (success with typed payload).
|
|
if (request.Command.Kind == MxCommandKind.Register)
|
|
{
|
|
return Task.FromResult(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.Register,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
Register = new RegisterReply { ServerHandle = 1 },
|
|
});
|
|
}
|
|
|
|
// Reply 2 = SubscribeBulk (success).
|
|
if (request.Command.Kind == MxCommandKind.SubscribeBulk)
|
|
{
|
|
var subscribeReply = new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.SubscribeBulk,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
SubscribeBulk = new BulkSubscribeReply(),
|
|
};
|
|
return Task.FromResult(subscribeReply);
|
|
}
|
|
|
|
// ReadBulk reply 1 = success (so the steady-state loop enters
|
|
// and starts iterating). Reply 2+ = simulated cancellation.
|
|
if (request.Command.Kind == MxCommandKind.ReadBulk && n <= 3)
|
|
{
|
|
return Task.FromResult(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.ReadBulk,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
ReadBulk = new BulkReadReply(),
|
|
});
|
|
}
|
|
|
|
// From here on every ReadBulk throws OCE — the steady-state
|
|
// loop must exit promptly rather than spinning until
|
|
// --duration-seconds elapses.
|
|
throw new OperationCanceledException();
|
|
},
|
|
};
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
|
await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"bench-read-bulk",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--duration-seconds",
|
|
"30",
|
|
"--warmup-seconds",
|
|
"0",
|
|
"--bulk-size",
|
|
"1",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient));
|
|
sw.Stop();
|
|
|
|
// Without the fix the loop swallows OCE and continues until the 30 s
|
|
// steady-state deadline expires. With the fix it exits as soon as OCE
|
|
// surfaces. Generous 10 s ceiling to keep the test stable under load.
|
|
Assert.True(
|
|
sw.Elapsed < TimeSpan.FromSeconds(10),
|
|
$"Bench did not exit promptly on cancellation; took {sw.Elapsed}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Both `ReadBulkAsync` and `BenchReadBulkAsync` cast
|
|
/// the user-supplied <c>--timeout-ms</c> to <see cref="uint"/> without
|
|
/// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps
|
|
/// to ~49.7 days. The fix must reject negatives with a clear error.
|
|
/// </summary>
|
|
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[InlineData("read-bulk")]
|
|
[InlineData("bench-read-bulk")]
|
|
public async Task RunAsync_TimeoutMs_NegativeValue_RejectsWithClearError(string command)
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
|
|
string[] args = command is "read-bulk"
|
|
? [
|
|
"read-bulk",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--session-id",
|
|
"session-fixture",
|
|
"--server-handle",
|
|
"1",
|
|
"--items",
|
|
"Area001.Pump001.Speed",
|
|
"--timeout-ms",
|
|
"-1",
|
|
]
|
|
: [
|
|
"bench-read-bulk",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--duration-seconds",
|
|
"1",
|
|
"--warmup-seconds",
|
|
"0",
|
|
"--bulk-size",
|
|
"1",
|
|
"--timeout-ms",
|
|
"-1",
|
|
];
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
args,
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.NotEqual(0, exitCode);
|
|
string err = error.ToString();
|
|
Assert.Contains("timeout-ms", err);
|
|
Assert.Contains("non-negative", err);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Locates the .NET client README by walking up from the test assembly's
|
|
/// base directory until <c>clients/dotnet/README.md</c> is found. Keeps
|
|
/// the regression test independent of the current working directory.
|
|
/// </summary>
|
|
private static string LocateClientReadme()
|
|
{
|
|
string? directory = AppContext.BaseDirectory;
|
|
while (!string.IsNullOrEmpty(directory))
|
|
{
|
|
string candidate = Path.Combine(directory, "clients", "dotnet", "README.md");
|
|
if (File.Exists(candidate))
|
|
{
|
|
return candidate;
|
|
}
|
|
|
|
directory = Path.GetDirectoryName(directory);
|
|
}
|
|
|
|
throw new FileNotFoundException("clients/dotnet/README.md not found above test assembly base directory.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the documented CLI invocation for the requested subcommand
|
|
/// from the README, returning only the arguments after the
|
|
/// <c>mxgw-dotnet</c>-equivalent prefix so they can be passed straight
|
|
/// to <see cref="MxGatewayClientCli.RunAsync"/>.
|
|
/// </summary>
|
|
private static string[] ExtractReadmeCommandLine(string readmePath, string command)
|
|
{
|
|
string[] lines = File.ReadAllLines(readmePath);
|
|
// Look for the documented `dotnet run ... -- <command> ...` line.
|
|
foreach (string line in lines)
|
|
{
|
|
int dashes = line.IndexOf("-- " + command, StringComparison.Ordinal);
|
|
if (dashes < 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string after = line[(dashes + 3)..].Trim();
|
|
// Tokenize by whitespace, respecting "..." quoted segments.
|
|
return TokenizeCommandLine(after);
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
$"README at '{readmePath}' has no documented example for subcommand '{command}'.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Splits a single command-line string into argv tokens, honouring
|
|
/// double-quoted segments so paths with embedded spaces survive intact.
|
|
/// </summary>
|
|
private static string[] TokenizeCommandLine(string input)
|
|
{
|
|
var tokens = new List<string>();
|
|
var current = new System.Text.StringBuilder();
|
|
bool inQuotes = false;
|
|
|
|
foreach (char ch in input)
|
|
{
|
|
if (ch == '"')
|
|
{
|
|
inQuotes = !inQuotes;
|
|
continue;
|
|
}
|
|
|
|
if (!inQuotes && char.IsWhiteSpace(ch))
|
|
{
|
|
if (current.Length > 0)
|
|
{
|
|
tokens.Add(current.ToString());
|
|
current.Clear();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
current.Append(ch);
|
|
}
|
|
|
|
if (current.Length > 0)
|
|
{
|
|
tokens.Add(current.ToString());
|
|
}
|
|
|
|
return tokens.ToArray();
|
|
}
|
|
|
|
/// <summary>Fake CLI client for testing.</summary>
|
|
private sealed class FakeCliClient : IMxGatewayCliClient
|
|
{
|
|
/// <summary>Queue of invoke replies to return.</summary>
|
|
public Queue<MxCommandReply> InvokeReplies { get; } = new();
|
|
|
|
/// <summary>List of received invoke requests.</summary>
|
|
public List<MxCommandRequest> InvokeRequests { get; } = [];
|
|
|
|
/// <summary>List of received close session requests.</summary>
|
|
public List<CloseSessionRequest> CloseSessionRequests { get; } = [];
|
|
|
|
/// <summary>List of events to yield when streaming.</summary>
|
|
public List<MxEvent> Events { get; } = [];
|
|
|
|
/// <summary>Exception to throw on invoke, if any.</summary>
|
|
public Exception? InvokeFailure { get; init; }
|
|
|
|
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
|
|
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
|
|
|
|
/// <summary>Disposes the fake client. No-op; there are no unmanaged resources to release.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<OpenSessionReply> OpenSessionAsync(
|
|
OpenSessionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(new OpenSessionReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
GatewayProtocolVersion = 1,
|
|
WorkerProtocolVersion = 1,
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<CloseSessionReply> CloseSessionAsync(
|
|
CloseSessionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
CloseSessionRequests.Add(request);
|
|
return Task.FromResult(new CloseSessionReply
|
|
{
|
|
SessionId = request.SessionId,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
FinalState = SessionState.Closed,
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<MxCommandReply> InvokeAsync(
|
|
MxCommandRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
InvokeRequests.Add(request);
|
|
if (InvokeFailure is not null)
|
|
{
|
|
throw InvokeFailure;
|
|
}
|
|
|
|
if (InvokeHandler is not null)
|
|
{
|
|
return InvokeHandler(request, cancellationToken);
|
|
}
|
|
|
|
return Task.FromResult(InvokeReplies.Dequeue());
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
|
StreamEventsRequest request,
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
foreach (MxEvent gatewayEvent in Events)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await Task.Yield();
|
|
yield return gatewayEvent;
|
|
}
|
|
}
|
|
|
|
/// <summary>Queue of acknowledge-alarm replies to return.</summary>
|
|
public Queue<AcknowledgeAlarmReply> AcknowledgeAlarmReplies { get; } = new();
|
|
|
|
/// <summary>List of received acknowledge-alarm requests.</summary>
|
|
public List<AcknowledgeAlarmRequest> AcknowledgeAlarmRequests { get; } = [];
|
|
|
|
/// <summary>List of received stream-alarms requests.</summary>
|
|
public List<StreamAlarmsRequest> StreamAlarmsRequests { get; } = [];
|
|
|
|
/// <summary>List of alarm feed messages to yield when streaming alarms.</summary>
|
|
public List<AlarmFeedMessage> AlarmFeedMessages { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
|
AcknowledgeAlarmRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
AcknowledgeAlarmRequests.Add(request);
|
|
return Task.FromResult(AcknowledgeAlarmReplies.Dequeue());
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
|
StreamAlarmsRequest request,
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
StreamAlarmsRequests.Add(request);
|
|
foreach (AlarmFeedMessage feedMessage in AlarmFeedMessages)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await Task.Yield();
|
|
yield return feedMessage;
|
|
}
|
|
}
|
|
|
|
/// <summary>Galaxy test connection reply to return.</summary>
|
|
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
|
|
|
|
/// <summary>Galaxy get last deploy time reply to return.</summary>
|
|
public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false };
|
|
|
|
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
|
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
|
|
|
/// <summary>Queue of galaxy discover hierarchy replies to return.</summary>
|
|
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
|
|
|
/// <summary>List of received galaxy test connection requests.</summary>
|
|
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
|
|
|
|
/// <summary>List of received galaxy get last deploy time requests.</summary>
|
|
public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = [];
|
|
|
|
/// <summary>List of received galaxy discover hierarchy requests.</summary>
|
|
public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
|
TestConnectionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
GalaxyTestConnectionRequests.Add(request);
|
|
return Task.FromResult(GalaxyTestConnectionReply);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
|
|
GetLastDeployTimeRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
GalaxyGetLastDeployTimeRequests.Add(request);
|
|
return Task.FromResult(GalaxyGetLastDeployTimeReply);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
|
|
DiscoverHierarchyRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
GalaxyDiscoverHierarchyRequests.Add(request);
|
|
return Task.FromResult(
|
|
GalaxyDiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
|
|
? reply
|
|
: GalaxyDiscoverHierarchyReply);
|
|
}
|
|
|
|
/// <summary>List of received galaxy watch deploy events requests.</summary>
|
|
public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = [];
|
|
|
|
/// <summary>List of deploy events to yield when watching.</summary>
|
|
public List<DeployEvent> GalaxyDeployEvents { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
|
|
WatchDeployEventsRequest request,
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
GalaxyWatchDeployEventsRequests.Add(request);
|
|
foreach (DeployEvent deployEvent in GalaxyDeployEvents)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await Task.Yield();
|
|
yield return deployEvent;
|
|
}
|
|
}
|
|
|
|
/// <summary>List of received galaxy browse-children requests, in call order.</summary>
|
|
public List<BrowseChildrenRequest> GalaxyBrowseChildrenRequests { get; } = [];
|
|
|
|
/// <summary>
|
|
/// Per-parent browse-children replies keyed by <c>parent_gobject_id</c>
|
|
/// (0 = root). Each parent's queue is dequeued in page order; an absent
|
|
/// or exhausted queue yields an empty reply.
|
|
/// </summary>
|
|
public Dictionary<int, Queue<BrowseChildrenReply>> GalaxyBrowseChildrenReplies { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public Task<BrowseChildrenReply> GalaxyBrowseChildrenAsync(
|
|
BrowseChildrenRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
GalaxyBrowseChildrenRequests.Add(request);
|
|
int parentId = request.ParentCase == BrowseChildrenRequest.ParentOneofCase.ParentGobjectId
|
|
? request.ParentGobjectId
|
|
: 0;
|
|
if (GalaxyBrowseChildrenReplies.TryGetValue(parentId, out Queue<BrowseChildrenReply>? queue)
|
|
&& queue.TryDequeue(out BrowseChildrenReply? reply))
|
|
{
|
|
return Task.FromResult(reply);
|
|
}
|
|
|
|
return Task.FromResult(new BrowseChildrenReply());
|
|
}
|
|
}
|
|
}
|