fix(CLI-45): standardize the CLI credential env var and fail fast on empty passwords

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.
This commit is contained in:
Joseph Doherty
2026-08-07 06:03:24 -04:00
parent cf66ebbcfb
commit 37cb3b0df8
17 changed files with 764 additions and 53 deletions
@@ -235,7 +235,7 @@ public sealed class MxGatewayClientCliTests
"--session-id", "session-fixture",
"--server-handle", "12",
"--verify-user", "operator",
"--verify-user-password", password,
"--password", password,
],
output,
error,
@@ -246,6 +246,235 @@ public sealed class MxGatewayClientCliTests
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]