Merge branch 'fix/cli-45-credential-envvar'
# Conflicts: # archreview/2026-07-12/remediation/00-tracking.md
This commit is contained in:
@@ -264,6 +264,32 @@ optionally writes a value when `--type` and `--value` are supplied, reads a
|
||||
bounded event stream, and closes the session in a `finally` block. CLI error
|
||||
output redacts API keys supplied through `--api-key`.
|
||||
|
||||
### `authenticate-user` credentials
|
||||
|
||||
```powershell
|
||||
$env:MXGATEWAY_VERIFY_PASSWORD = "<verify-user password>"
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- authenticate-user --session-id <id> --server-handle 1 --verify-user operator --json
|
||||
```
|
||||
|
||||
The credential comes from `--password` or, preferably, the environment variable
|
||||
named by `--password-env` (default `MXGATEWAY_VERIFY_PASSWORD`) so it stays out
|
||||
of shell history and the process table. It is never echoed to stdout or stderr,
|
||||
and error output routes it through the same redaction seam as the API key. A
|
||||
missing or empty resolved credential is a usage error naming the option and the
|
||||
variable: the CLI fails before the invoke rather than authenticating with an
|
||||
empty password.
|
||||
|
||||
`MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all five client CLIs
|
||||
— see [Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
|
||||
|
||||
**Deprecated names.** This CLI previously used `--verify-user-password`,
|
||||
`--verify-user-password-env`, and `MXGATEWAY_VERIFY_USER_PASSWORD`. All three
|
||||
still resolve, for one release only, so existing scripts keep working; migrate to
|
||||
the canonical names above. The full resolution order is `--password`,
|
||||
`--verify-user-password`, the variable named by `--password-env` (or the
|
||||
deprecated `--verify-user-password-env`, default `MXGATEWAY_VERIFY_PASSWORD`),
|
||||
then `MXGATEWAY_VERIFY_USER_PASSWORD`.
|
||||
|
||||
## Galaxy Repository Browse
|
||||
|
||||
`GalaxyRepositoryClient` is a separate read-only wrapper around the
|
||||
|
||||
@@ -346,31 +346,78 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
/// <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"/>.
|
||||
/// Canonical CLI credential environment variable, shared by every official
|
||||
/// client CLI (CLI-45) so one exported variable drives the same operator
|
||||
/// workflow in all five languages.
|
||||
/// </summary>
|
||||
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Pre-CLI-45 environment variable, still honoured as a deprecated fallback
|
||||
/// for one release so existing scripts keep working.
|
||||
/// </summary>
|
||||
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the name of the environment variable holding the verify-user
|
||||
/// credential: <c>--password-env</c>, then the deprecated
|
||||
/// <c>--verify-user-password-env</c> alias, then
|
||||
/// <c>MXGATEWAY_VERIFY_PASSWORD</c>.
|
||||
/// </summary>
|
||||
private static string ResolveVerifyPasswordEnvironmentName(CliArguments arguments)
|
||||
{
|
||||
string? environmentName = arguments.GetOptional("password-env");
|
||||
if (!string.IsNullOrEmpty(environmentName))
|
||||
{
|
||||
return environmentName;
|
||||
}
|
||||
|
||||
environmentName = arguments.GetOptional("verify-user-password-env");
|
||||
return string.IsNullOrEmpty(environmentName)
|
||||
? DefaultVerifyPasswordEnvironmentName
|
||||
: environmentName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective MXAccess verify-user credential in the CLI-45
|
||||
/// order: <c>--password</c>, the deprecated <c>--verify-user-password</c>
|
||||
/// alias, the environment variable named by <c>--password-env</c> (default
|
||||
/// <c>MXGATEWAY_VERIFY_PASSWORD</c>), then the deprecated
|
||||
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>. An empty value from any source is
|
||||
/// treated as absent. 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");
|
||||
string? password = arguments.GetOptional("password");
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
return password;
|
||||
}
|
||||
|
||||
string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env")
|
||||
?? "MXGATEWAY_VERIFY_USER_PASSWORD";
|
||||
password = arguments.GetOptional("verify-user-password");
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
return password;
|
||||
}
|
||||
|
||||
return Environment.GetEnvironmentVariable(passwordEnvironmentName);
|
||||
password = Environment.GetEnvironmentVariable(ResolveVerifyPasswordEnvironmentName(arguments));
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
return password;
|
||||
}
|
||||
|
||||
password = Environment.GetEnvironmentVariable(LegacyVerifyPasswordEnvironmentName);
|
||||
return string.IsNullOrEmpty(password) ? null : password;
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// a redaction-safe error when no source yields a non-empty value. Failing
|
||||
/// fast keeps a misconfigured environment from becoming a real MXAccess
|
||||
/// authentication attempt with an empty credential (CLI-45); the thrown
|
||||
/// message names only the option/env var, never the value.
|
||||
/// </summary>
|
||||
private static string ResolveVerifyUserPassword(CliArguments arguments)
|
||||
{
|
||||
@@ -380,11 +427,10 @@ public static class MxGatewayClientCli
|
||||
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}.");
|
||||
"Verify-user password is required. Pass --password or set "
|
||||
+ $"{ResolveVerifyPasswordEnvironmentName(arguments)} (deprecated aliases: "
|
||||
+ $"--verify-user-password, --verify-user-password-env, {LegacyVerifyPasswordEnvironmentName}).");
|
||||
}
|
||||
|
||||
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
|
||||
@@ -710,8 +756,10 @@ public static class MxGatewayClientCli
|
||||
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
|
||||
// The credential is resolved from --password or its env var (default
|
||||
// MXGATEWAY_VERIFY_PASSWORD) and is never echoed; a missing or empty value
|
||||
// fails fast before the invoke rather than reaching the wire (CLI-45).
|
||||
// On any surfaced error the RunCoreAsync catch block routes
|
||||
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
@@ -2372,7 +2420,9 @@ public static class MxGatewayClientCli
|
||||
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 authenticate-user --session-id <id> --server-handle <n> --verify-user <user> [--password <pw>] [--password-env <ENVVAR>] [--json]");
|
||||
writer.WriteLine(" credential: --password, else the --password-env variable (default MXGATEWAY_VERIFY_PASSWORD); required and never empty.");
|
||||
writer.WriteLine(" deprecated aliases: --verify-user-password, --verify-user-password-env, MXGATEWAY_VERIFY_USER_PASSWORD.");
|
||||
writer.WriteLine("mxgw-dotnet archestra-user-to-id --session-id <id> --server-handle <n> --user-guid <guid> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -183,7 +183,11 @@ 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`.
|
||||
`write-secured`. The credential is required: a missing or empty resolved value is
|
||||
a usage error naming the flag and the variable, so the CLI fails before dialing
|
||||
instead of authenticating with an empty password. `MXGATEWAY_VERIFY_PASSWORD` is
|
||||
the canonical variable across all five client CLIs — see
|
||||
[Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
|
||||
|
||||
### Array writes replace the whole array
|
||||
|
||||
|
||||
@@ -446,6 +446,11 @@ func runWriteSecured(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
return writeCommandOutput(stdout, *jsonOutput, "write-secured", options, reply, err)
|
||||
}
|
||||
|
||||
// defaultVerifyPasswordEnv is the canonical CLI credential environment variable,
|
||||
// shared by every official client CLI (CLI-45) so one exported variable drives
|
||||
// the same operator workflow in all five languages.
|
||||
const defaultVerifyPasswordEnv = "MXGATEWAY_VERIFY_PASSWORD"
|
||||
|
||||
func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("authenticate-user", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -458,7 +463,7 @@ func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.W
|
||||
// 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")
|
||||
passwordEnv := flags.String("password-env", defaultVerifyPasswordEnv, "environment variable containing the verify-user password")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -471,8 +476,18 @@ func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.W
|
||||
}
|
||||
|
||||
resolvedPassword := *password
|
||||
if resolvedPassword == "" && *passwordEnv != "" {
|
||||
resolvedPassword = os.Getenv(*passwordEnv)
|
||||
envName := *passwordEnv
|
||||
if envName == "" {
|
||||
envName = defaultVerifyPasswordEnv
|
||||
}
|
||||
if resolvedPassword == "" {
|
||||
resolvedPassword = os.Getenv(envName)
|
||||
}
|
||||
// Fail fast rather than dialing: an unset or empty variable must not become a
|
||||
// real MXAccess authentication attempt with an empty credential. The message
|
||||
// names only the flag and the variable — never the resolved value.
|
||||
if resolvedPassword == "" {
|
||||
return fmt.Errorf("a password is required via -password or the %s environment variable", envName)
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
|
||||
@@ -598,6 +598,69 @@ func TestRunAuthenticateUserRequiresVerifyUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthenticateUserRejectsEmptyPassword pins the CLI-45 fail-fast contract:
|
||||
// an unresolved credential must abort before dialing rather than authenticating
|
||||
// with an empty password, and the usage error must name both -password and the
|
||||
// canonical environment variable without echoing any value.
|
||||
func TestRunAuthenticateUserRejectsEmptyPassword(t *testing.T) {
|
||||
t.Setenv("MXGATEWAY_VERIFY_PASSWORD", "")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{
|
||||
"authenticate-user",
|
||||
"-session-id", "s1",
|
||||
"-verify-user", "operator",
|
||||
"-plaintext",
|
||||
"-api-key", "test",
|
||||
}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("authenticate-user without a credential must fail before dialing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "-password") {
|
||||
t.Fatalf("error must name the -password flag: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MXGATEWAY_VERIFY_PASSWORD") {
|
||||
t.Fatalf("error must name the canonical environment variable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthenticateUserReadsPasswordFromCanonicalEnv pins that the default
|
||||
// -password-env is MXGATEWAY_VERIFY_PASSWORD: with it set the credential guard
|
||||
// passes and the command proceeds past it to the dial, which fails against an
|
||||
// unused port under a short context — proving the guard was cleared without
|
||||
// needing a live gateway.
|
||||
func TestRunAuthenticateUserReadsPasswordFromCanonicalEnv(t *testing.T) {
|
||||
t.Setenv("MXGATEWAY_VERIFY_PASSWORD", "env-sourced-credential")
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(ctx, []string{
|
||||
"authenticate-user",
|
||||
"-session-id", "s1",
|
||||
"-verify-user", "operator",
|
||||
"-endpoint", "127.0.0.1:1",
|
||||
"-plaintext",
|
||||
"-api-key", "test",
|
||||
"-call-timeout", "1s",
|
||||
}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("expected the dial/RPC to fail against an unused port")
|
||||
}
|
||||
if strings.Contains(err.Error(), "flag provided but not defined") {
|
||||
t.Fatalf("test invoked an unknown flag, so it never reached the guard: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "a password is required") {
|
||||
t.Fatalf("credential guard must be satisfied from %s: %v", "MXGATEWAY_VERIFY_PASSWORD", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "env-sourced-credential") ||
|
||||
strings.Contains(stdout.String(), "env-sourced-credential") ||
|
||||
strings.Contains(stderr.String(), "env-sourced-credential") {
|
||||
t.Fatal("the resolved credential must never be echoed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
|
||||
// guard so a write-bulk with unequal item-handles / values counts fails fast
|
||||
// before any dial.
|
||||
|
||||
@@ -172,8 +172,13 @@ session.write(serverHandle, itemHandle, value, userId);
|
||||
native failure is surfaced, not papered over.
|
||||
|
||||
The CLI exposes `advise-supervisory`, `write-secured`, and `authenticate-user`
|
||||
(credential via `--password` or `--password-env`, never echoed), and `write` /
|
||||
`write2` take `--user-id`.
|
||||
(credential via `--password` or the variable named by `--password-env`, default
|
||||
`MXGATEWAY_VERIFY_PASSWORD`, never echoed), and `write` / `write2` take
|
||||
`--user-id`. The credential is required: a missing or empty resolved value is a
|
||||
picocli usage error naming the option and the variable, so the CLI fails before
|
||||
connecting instead of authenticating with an empty password.
|
||||
`MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all five client
|
||||
CLIs — see [Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
|
||||
|
||||
### Array writes replace the whole array
|
||||
|
||||
|
||||
+21
-4
@@ -70,6 +70,7 @@ import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Model.CommandSpec;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.ParameterException;
|
||||
import picocli.CommandLine.Spec;
|
||||
|
||||
/**
|
||||
@@ -182,6 +183,13 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
/** Sentinel written to stdout after every command result in batch mode. */
|
||||
static final String BATCH_EOR = "__MXGW_BATCH_EOR__";
|
||||
|
||||
/**
|
||||
* Canonical CLI credential environment variable, shared by every official
|
||||
* client CLI (CLI-45) so one exported variable drives the same operator
|
||||
* workflow in all five languages.
|
||||
*/
|
||||
static final String DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD";
|
||||
|
||||
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
|
||||
private static final Object ALARM_FEED_END = new Object();
|
||||
|
||||
@@ -1139,7 +1147,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
|
||||
@Option(
|
||||
names = "--password-env",
|
||||
defaultValue = "MXGATEWAY_VERIFY_PASSWORD",
|
||||
defaultValue = DEFAULT_VERIFY_PASSWORD_ENV,
|
||||
description = "Environment variable holding the password when --password is omitted.")
|
||||
String passwordEnv;
|
||||
|
||||
@@ -1151,11 +1159,20 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
public Integer call() {
|
||||
// Resolve the credential from the flag or environment. It flows only
|
||||
// into the request; it is never written to output, logs, or errors.
|
||||
String environmentName =
|
||||
passwordEnv == null || passwordEnv.isBlank() ? DEFAULT_VERIFY_PASSWORD_ENV : passwordEnv;
|
||||
String resolvedPassword = password == null || password.isBlank()
|
||||
? System.getenv(passwordEnv)
|
||||
? System.getenv(environmentName)
|
||||
: password;
|
||||
if (resolvedPassword == null) {
|
||||
resolvedPassword = "";
|
||||
if (resolvedPassword == null || resolvedPassword.isBlank()) {
|
||||
// Fail fast instead of dialing: a misconfigured environment must not
|
||||
// become a real MXAccess authentication attempt with an empty
|
||||
// credential (CLI-45). The message names the option and the variable
|
||||
// only — never the value.
|
||||
throw new ParameterException(
|
||||
common.spec.commandLine(),
|
||||
"a password is required via --password or the " + environmentName
|
||||
+ " environment variable");
|
||||
}
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
int userId = client.session(sessionId)
|
||||
|
||||
+70
@@ -2,7 +2,10 @@ package com.zb.mom.ww.mxgateway.cli;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
|
||||
@@ -211,6 +214,73 @@ final class MxGatewayCliTests {
|
||||
assertFalse(run.errors().contains("super-secret-pw"), "password must never be echoed to stderr");
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-45: an unresolved credential must abort with a picocli usage error
|
||||
* before the CLI dials, instead of authenticating with an empty password.
|
||||
* The message names the option and the variable, never a value.
|
||||
*/
|
||||
@Test
|
||||
void authenticateUserRejectsMissingCredentialWithUsageError() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"authenticate-user",
|
||||
"--session-id", "session-cli",
|
||||
"--server-handle", "3",
|
||||
"--verify-user", "operator",
|
||||
"--password-env", "MXGW_CLI45_ABSENT_PASSWORD_VAR",
|
||||
"--json");
|
||||
|
||||
assertNotEquals(0, run.exitCode(), "a missing credential must fail");
|
||||
assertTrue(run.errors().contains("--password"), run.errors());
|
||||
assertTrue(run.errors().contains("MXGW_CLI45_ABSENT_PASSWORD_VAR"), run.errors());
|
||||
assertNull(factory.client, "the CLI must not connect without a credential");
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-45: a blank {@code --password} is treated as missing — the CLI never
|
||||
* sends a fabricated empty credential to the wire.
|
||||
*/
|
||||
@Test
|
||||
void authenticateUserRejectsBlankPasswordValue() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"authenticate-user",
|
||||
"--session-id", "session-cli",
|
||||
"--server-handle", "3",
|
||||
"--verify-user", "operator",
|
||||
"--password", "",
|
||||
"--password-env", "MXGW_CLI45_ABSENT_PASSWORD_VAR",
|
||||
"--json");
|
||||
|
||||
assertNotEquals(0, run.exitCode(), "a blank credential must fail");
|
||||
assertNull(factory.client, "the CLI must not connect without a credential");
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-45: {@code --password-env} defaults to the canonical
|
||||
* {@code MXGATEWAY_VERIFY_PASSWORD}, so the usage error names it when no
|
||||
* explicit variable is given. Skipped if the canonical variable happens to be
|
||||
* exported in the running environment (which would satisfy the credential).
|
||||
*/
|
||||
@Test
|
||||
void authenticateUserDefaultsToCanonicalPasswordEnvName() {
|
||||
assumeTrue(System.getenv(MxGatewayCli.DEFAULT_VERIFY_PASSWORD_ENV) == null);
|
||||
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"authenticate-user",
|
||||
"--session-id", "session-cli",
|
||||
"--server-handle", "3",
|
||||
"--verify-user", "operator",
|
||||
"--json");
|
||||
|
||||
assertNotEquals(0, run.exitCode());
|
||||
assertTrue(run.errors().contains("MXGATEWAY_VERIFY_PASSWORD"), run.errors());
|
||||
}
|
||||
|
||||
// ---- ping subcommand (D4) ----
|
||||
|
||||
@Test
|
||||
|
||||
@@ -187,7 +187,13 @@ await session.write_secured(
|
||||
```
|
||||
|
||||
The CLI mirrors these as `authenticate-user` (credential via `--password` or,
|
||||
preferably, `--password-env`) and `write-secured`.
|
||||
preferably, the variable named by `--password-env`, default
|
||||
`MXGATEWAY_VERIFY_PASSWORD`) and `write-secured`. The credential is required: a
|
||||
missing or empty resolved value raises a `UsageError` naming the option and the
|
||||
variable, so the CLI fails before connecting instead of authenticating with an
|
||||
empty password. `MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all
|
||||
five client CLIs — see
|
||||
[Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
|
||||
|
||||
### Array writes replace the whole array
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_AGGREGATE_EVENTS = 10_000
|
||||
|
||||
#: Canonical CLI credential environment variable, shared by every official client
|
||||
#: CLI (CLI-45) so one exported variable drives the same operator workflow in all
|
||||
#: five languages.
|
||||
DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD"
|
||||
|
||||
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
|
||||
@@ -328,7 +333,8 @@ def write_secured(**kwargs: Any) -> None:
|
||||
)
|
||||
@click.option(
|
||||
"--password-env",
|
||||
default=None,
|
||||
default=DEFAULT_VERIFY_PASSWORD_ENV,
|
||||
show_default=True,
|
||||
help="Environment variable holding the user password.",
|
||||
)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@@ -834,17 +840,23 @@ async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
|
||||
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.
|
||||
Prefers the explicit flag, then falls back to the environment variable named
|
||||
by ``--password-env`` (default :data:`DEFAULT_VERIFY_PASSWORD_ENV`). A missing
|
||||
*or empty* value from either source is a usage error (CLI-45): the CLI never
|
||||
sends a fabricated empty credential to the wire. The error names the option
|
||||
and the variable only — the resolved secret is never echoed, and callers pass
|
||||
it into the ``secrets`` redaction list so it cannot leak through a surfaced
|
||||
error either.
|
||||
"""
|
||||
|
||||
env_name = kwargs.get("password_env") or DEFAULT_VERIFY_PASSWORD_ENV
|
||||
password = kwargs.get("password")
|
||||
if not password:
|
||||
env_name = kwargs.get("password_env")
|
||||
password = os.environ.get(env_name) if env_name else None
|
||||
password = os.environ.get(env_name)
|
||||
if not password:
|
||||
raise click.UsageError("a password is required via --password or --password-env")
|
||||
raise click.UsageError(
|
||||
f"a password is required via --password or the {env_name} environment variable"
|
||||
)
|
||||
return password
|
||||
|
||||
|
||||
|
||||
@@ -752,7 +752,9 @@ def test_authenticate_user_reads_password_from_env(monkeypatch: pytest.MonkeyPat
|
||||
assert fake.last_request.command.authenticate_user.verify_user_password == "env-secret-pw"
|
||||
|
||||
|
||||
def test_authenticate_user_requires_a_password() -> None:
|
||||
def test_authenticate_user_requires_a_password(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("MXGATEWAY_VERIFY_PASSWORD", raising=False)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
@@ -770,6 +772,81 @@ def test_authenticate_user_requires_a_password() -> None:
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "password is required" in result.output
|
||||
# CLI-45: the usage error names the option and the canonical env var.
|
||||
assert "--password" in result.output
|
||||
assert "MXGATEWAY_VERIFY_PASSWORD" in result.output
|
||||
|
||||
|
||||
def test_authenticate_user_reads_password_from_canonical_default_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""CLI-45: --password-env defaults to MXGATEWAY_VERIFY_PASSWORD.
|
||||
|
||||
Exporting the canonical variable alone must satisfy the credential, with no
|
||||
explicit --password-env flag — the same operator workflow as the other CLIs.
|
||||
"""
|
||||
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=11),
|
||||
)
|
||||
fake = _FakeInvokeClient(reply)
|
||||
|
||||
async def fake_connect(options, **_kwargs):
|
||||
return fake
|
||||
|
||||
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
|
||||
monkeypatch.setenv("MXGATEWAY_VERIFY_PASSWORD", "canonical-env-pw")
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"authenticate-user",
|
||||
"--plaintext",
|
||||
"--session-id",
|
||||
"s1",
|
||||
"--server-handle",
|
||||
"3",
|
||||
"--verify-user",
|
||||
"operator",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.output)["userId"] == 11
|
||||
assert "canonical-env-pw" not in result.output
|
||||
assert fake.last_request.command.authenticate_user.verify_user_password == "canonical-env-pw"
|
||||
|
||||
|
||||
def test_authenticate_user_rejects_empty_password_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""CLI-45: an empty resolved credential fails fast, never reaching the wire."""
|
||||
monkeypatch.setenv("MXGATEWAY_VERIFY_PASSWORD", "")
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"authenticate-user",
|
||||
"--plaintext",
|
||||
"--session-id",
|
||||
"s1",
|
||||
"--server-handle",
|
||||
"3",
|
||||
"--verify-user",
|
||||
"operator",
|
||||
"--password",
|
||||
"",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "password is required" in result.output
|
||||
|
||||
|
||||
def test_write_secured_command_does_not_echo_value_on_failure(
|
||||
|
||||
@@ -216,7 +216,12 @@ 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`.
|
||||
variable named by `--password-env`, default `MXGATEWAY_VERIFY_PASSWORD`, never
|
||||
echoed) and `write-secured`. The credential is required: a missing or empty
|
||||
resolved value is a usage error naming the flag and the variable, so the CLI
|
||||
fails before dialing instead of authenticating with an empty password.
|
||||
`MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all five client
|
||||
CLIs — see [Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
|
||||
|
||||
The remaining single-item command helpers round out MXAccess parity:
|
||||
`unregister`, `suspend` / `activate` (each returns the operation's
|
||||
|
||||
@@ -752,17 +752,7 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
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 verify_user_password = resolve_verify_user_password(password, &password_env)?;
|
||||
let session = session_for(connection, session_id).await?;
|
||||
let user_id = session
|
||||
.authenticate_user(server_handle, &verify_user, &verify_user_password)
|
||||
@@ -1736,6 +1726,31 @@ fn print_ok(operation: &str, use_json: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the `authenticate-user` credential from `--password`, falling back to
|
||||
/// the environment variable named by `--password-env` (default
|
||||
/// `MXGATEWAY_VERIFY_PASSWORD`).
|
||||
///
|
||||
/// An empty value from either source counts as missing (CLI-45): the CLI fails
|
||||
/// fast with a usage error rather than sending a fabricated empty credential to
|
||||
/// the wire. The error names the flag and the variable only — never the value,
|
||||
/// which is never echoed to stdout/stderr or embedded in an error message.
|
||||
fn resolve_verify_user_password(
|
||||
password: Option<String>,
|
||||
password_env: &str,
|
||||
) -> Result<String, Error> {
|
||||
password
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
env::var(password_env)
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.ok_or_else(|| Error::InvalidArgument {
|
||||
name: "password".to_owned(),
|
||||
detail: format!("supply --password or set the environment variable `{password_env}`"),
|
||||
})
|
||||
}
|
||||
|
||||
fn print_bulk_results(
|
||||
operation: &str,
|
||||
results: &[zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::SubscribeResult],
|
||||
@@ -2617,6 +2632,66 @@ mod tests {
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
/// CLI-45: `--password-env` must default to the canonical
|
||||
/// `MXGATEWAY_VERIFY_PASSWORD` shared by every official client CLI.
|
||||
#[test]
|
||||
fn authenticate_user_password_env_defaults_to_canonical_name() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"mxgw",
|
||||
"authenticate-user",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--server-handle",
|
||||
"7",
|
||||
"--verify-user",
|
||||
"verifier",
|
||||
])
|
||||
.expect("parse");
|
||||
match parsed.command {
|
||||
Command::AuthenticateUser { password_env, .. } => {
|
||||
assert_eq!(password_env, "MXGATEWAY_VERIFY_PASSWORD");
|
||||
}
|
||||
other => panic!("expected authenticate-user, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// CLI-45: a credential that resolves to an empty string — whether from the
|
||||
/// flag or from the named environment variable — is treated as missing, so
|
||||
/// the CLI never sends a fabricated empty password to the wire. The usage
|
||||
/// error names the flag and the variable, never a value.
|
||||
#[test]
|
||||
fn resolve_verify_user_password_rejects_missing_and_empty_values() {
|
||||
const ABSENT: &str = "MXGW_CLI45_ABSENT_PASSWORD_VAR";
|
||||
const EMPTY: &str = "MXGW_CLI45_EMPTY_PASSWORD_VAR";
|
||||
const PRESENT: &str = "MXGW_CLI45_PRESENT_PASSWORD_VAR";
|
||||
std::env::remove_var(ABSENT);
|
||||
std::env::set_var(EMPTY, "");
|
||||
std::env::set_var(PRESENT, "env-sourced-credential");
|
||||
|
||||
for (password, env_name) in [
|
||||
(None, ABSENT),
|
||||
(Some(String::new()), ABSENT),
|
||||
(None, EMPTY),
|
||||
(Some(String::new()), EMPTY),
|
||||
] {
|
||||
let error = super::resolve_verify_user_password(password, env_name)
|
||||
.expect_err("empty or missing credential must be a usage error");
|
||||
let rendered = error.to_string();
|
||||
assert!(rendered.contains("--password"), "{rendered}");
|
||||
assert!(rendered.contains(env_name), "{rendered}");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
super::resolve_verify_user_password(None, PRESENT).expect("env credential"),
|
||||
"env-sourced-credential"
|
||||
);
|
||||
assert_eq!(
|
||||
super::resolve_verify_user_password(Some("flag-credential".to_owned()), EMPTY)
|
||||
.expect("flag credential"),
|
||||
"flag-credential"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_write_secured_command() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
|
||||
Reference in New Issue
Block a user