Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ValueSourceOptionsTests.cs
T
Joseph Doherty e2f433e849 feat(cli): read secret-bearing option values from a file or stdin (#137)
Every CLI option that can carry a secret gets a `--<name>-file <path|->`
sibling ('-' reads stdin), so the value stays out of the process list and
shell history: --password-file (global), --primary-config-file /
--backup-config-file (data-connection create|update), --auth-config-file
(external-system create|update), --connection-string-file (db-connection
create|update), --credentials-file (notification smtp create|update) and
--auth-token-file (notification sms create|update). Follows the existing
--config-file convention; `@path` is avoided because System.CommandLine
expands @-tokens as response files.

The value is read at parse time; a missing/unreadable/empty source, or '-'
on a terminal stdin, is a parse error naming option + path only, never the
content. Inline and file forms are mutually exclusive, at most one option
may read stdin, and one trailing line break is stripped. Inline forms keep
working.

docker/seed-sites.sh and docker-env2/seed-sites.sh now write the MxGateway
config (windev gateway key) to a mode-600 temp file removed by an EXIT trap
and pass --primary-config-file; seed-flow-outbound-demo.sh pipes its rig
connection string via --connection-string-file -. CLI README (new "Secrets
from a file or stdin" section + per-command tables), Component-CLI.md and
both docker READMEs updated.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 00:46:17 -05:00

302 lines
13 KiB
C#

using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// ScadaBridge#137: secret-bearing options take their value from a file (<c>--x-file path</c>) or
/// stdin (<c>--x-file -</c>) so it stays off the command line. Covers the file, stdin, inline and
/// missing-file cases, and that no error ever carries the file content.
/// </summary>
[Collection("Console")]
public sealed class ValueSourceOptionsTests : IDisposable
{
private const string Secret = "sk-137-DO-NOT-LEAK-7f3a9c";
private readonly string _dir = Directory.CreateTempSubdirectory("sb-cli-137-").FullName;
private readonly Func<TextReader> _origReader = ValueSourceOptions.StdinReader;
private readonly Func<bool> _origRedirected = ValueSourceOptions.StdinIsRedirected;
/// <summary>Restores the stdin hooks and deletes the temp directory.</summary>
public void Dispose()
{
ValueSourceOptions.StdinReader = _origReader;
ValueSourceOptions.StdinIsRedirected = _origRedirected;
Directory.Delete(_dir, recursive: true);
}
private string WriteFile(string content, string name = "value.txt")
{
var path = Path.Combine(_dir, name);
File.WriteAllText(path, content);
return path;
}
private static string MxConfig => $"{{\"endpoint\":\"http://gw:5120\",\"apiKey\":\"{Secret}\"}}";
private void FakeStdin(string content)
{
ValueSourceOptions.StdinIsRedirected = () => true;
ValueSourceOptions.StdinReader = () => new StringReader(content);
}
private static string AllErrors(ParseResult result) => string.Join("\n", result.Errors.Select(e => e.Message));
// ---- happy paths: the management payload carries the file / stdin / inline value ----
/// <summary>The primary config is read from a file into the payload.</summary>
[Fact]
public async Task PrimaryConfigFile_ReadsFileIntoPayload()
{
var path = WriteFile(MxConfig + "\n");
var payload = await SendAsync($"data-connection create --site-id 1 --name C --protocol MxGateway --primary-config-file {path}");
Assert.Equal(MxConfig, payload.GetProperty("primaryConfiguration").GetString());
}
/// <summary>'-' reads the value from stdin.</summary>
[Fact]
public async Task PrimaryConfigFile_Dash_ReadsStdin()
{
FakeStdin(MxConfig + "\r\n");
var payload = await SendAsync("data-connection update --id 4 --name C --protocol MxGateway --primary-config-file -");
Assert.Equal(MxConfig, payload.GetProperty("primaryConfiguration").GetString());
}
/// <summary>The inline form keeps working, and file + inline can mix across different options.</summary>
[Fact]
public async Task InlinePrimary_WithBackupFromFile_BothReachPayload()
{
var path = WriteFile(MxConfig);
var payload = await SendAsync(
$"data-connection create --site-id 1 --name C --protocol OpcUa --primary-config {{\"endpointUrl\":\"opc.tcp://a\"}} --backup-config-file {path}");
Assert.Equal("{\"endpointUrl\":\"opc.tcp://a\"}", payload.GetProperty("primaryConfiguration").GetString());
Assert.Equal(MxConfig, payload.GetProperty("backupConfiguration").GetString());
}
/// <summary>Only one trailing line break is stripped; inner whitespace is kept.</summary>
[Fact]
public async Task File_StripsExactlyOneTrailingLineBreak()
{
var path = WriteFile("Server=x;Password=" + Secret + "\n\n");
var payload = await SendAsync($"db-connection create --name Db --connection-string-file {path}");
Assert.Equal("Server=x;Password=" + Secret + "\n", payload.GetProperty("connectionString").GetString());
}
/// <summary>External-system auth config from a file.</summary>
[Fact]
public async Task AuthConfigFile_ReadsFileIntoPayload()
{
var path = WriteFile($"{{\"key\":\"{Secret}\"}}");
var payload = await SendAsync($"external-system create --name E --endpoint-url http://e --auth-type ApiKey --auth-config-file {path}");
Assert.Equal($"{{\"key\":\"{Secret}\"}}", payload.GetProperty("authConfiguration").GetString());
}
/// <summary>The SMS create requirement is satisfied by --auth-token-file.</summary>
[Fact]
public async Task SmsCreate_AuthTokenFile_SatisfiesRequirement()
{
FakeStdin(Secret);
var payload = await SendAsync("notification sms create --account-sid AC1 --from-number +15550100 --auth-token-file -");
Assert.Equal(Secret, payload.GetProperty("authToken").GetString());
}
/// <summary>SMTP credentials from a file on update.</summary>
[Fact]
public async Task SmtpUpdate_CredentialsFile_ReadsFileIntoPayload()
{
var path = WriteFile("user:" + Secret);
var payload = await SendAsync(
$"notification smtp update --id 1 --server s --port 25 --auth-mode Basic --from-address a@b.c --credentials-file {path}");
Assert.Equal("user:" + Secret, payload.GetProperty("credentials").GetString());
}
/// <summary>--password-file wins over the environment fallback and reaches the resolver.</summary>
[Fact]
public void PasswordFile_ResolvesPassword()
{
var path = WriteFile(Secret + "\n");
var root = CliApplication.BuildRoot();
var passwordOption = (Option<string>)root.Options.Single(o => o.Name == "--password");
var result = root.Parse($"site list --password-file {path}");
Assert.Empty(result.Errors);
Assert.Equal(Secret, CommandHelpers.ResolvePassword(result, passwordOption, new CliConfig { Password = "from-env" }));
var inline = root.Parse("site list --password inline");
Assert.Equal("inline", CommandHelpers.ResolvePassword(inline, passwordOption, new CliConfig { Password = "from-env" }));
var none = root.Parse("site list");
Assert.Equal("from-env", CommandHelpers.ResolvePassword(none, passwordOption, new CliConfig { Password = "from-env" }));
}
// ---- error paths: clear message, never the content ----
/// <summary>A missing file is a parse error naming the option and path.</summary>
[Fact]
public void MissingFile_IsParseError_NamingOptionAndPath()
{
var path = Path.Combine(_dir, "nope.json");
var result = CliApplication.BuildRoot().Parse($"data-connection create --site-id 1 --name C --protocol MxGateway --primary-config-file {path}");
var errors = AllErrors(result);
Assert.Contains("--primary-config-file", errors);
Assert.Contains(path, errors);
Assert.Contains("not found", errors);
}
/// <summary>A directory path is a read error, not a crash.</summary>
[Fact]
public void DirectoryPath_IsParseError()
{
var result = CliApplication.BuildRoot().Parse($"external-system create --name E --endpoint-url http://e --auth-type ApiKey --auth-config-file {_dir}");
Assert.Contains("--auth-config-file", AllErrors(result));
}
/// <summary>An empty file is refused.</summary>
[Fact]
public void EmptyFile_IsParseError()
{
var path = WriteFile("\n");
var result = CliApplication.BuildRoot().Parse($"db-connection create --name Db --connection-string-file {path}");
Assert.Contains("is empty", AllErrors(result));
}
/// <summary>'-' on a terminal stdin is refused instead of blocking.</summary>
[Fact]
public void Stdin_NotRedirected_IsParseError()
{
ValueSourceOptions.StdinIsRedirected = () => false;
ValueSourceOptions.StdinReader = () => throw new InvalidOperationException("must not read a terminal");
var result = CliApplication.BuildRoot().Parse("data-connection create --site-id 1 --name C --protocol MxGateway --primary-config-file -");
Assert.Contains("stdin is a terminal", AllErrors(result));
}
/// <summary>Inline + file for the same value is refused — and the error does not echo the file.</summary>
[Fact]
public void InlineAndFile_Together_IsParseError_WithoutContent()
{
var path = WriteFile(MxConfig);
var result = CliApplication.BuildRoot().Parse(
$"data-connection create --site-id 1 --name C --protocol MxGateway --primary-config {{}} --primary-config-file {path}");
var errors = AllErrors(result);
Assert.Contains("not both", errors);
Assert.DoesNotContain(Secret, errors);
}
/// <summary>Two options cannot both read stdin.</summary>
[Fact]
public void TwoStdinReaders_IsParseError()
{
FakeStdin(MxConfig);
var result = CliApplication.BuildRoot().Parse(
"data-connection create --site-id 1 --name C --protocol MxGateway --primary-config-file - --password-file -");
Assert.Contains("Only one option can read stdin", AllErrors(result));
}
/// <summary>--password and --password-file together are refused.</summary>
[Fact]
public void PasswordAndPasswordFile_Together_IsParseError()
{
var path = WriteFile(Secret);
var result = CliApplication.BuildRoot().Parse($"site list --password p --password-file {path}");
Assert.Contains("--password-file", AllErrors(result));
}
/// <summary>The connection string stays required — either form satisfies it.</summary>
[Fact]
public void DbConnection_NeitherForm_IsRequiredError()
{
var result = CliApplication.BuildRoot().Parse("db-connection create --name Db");
Assert.Contains("--connection-string", AllErrors(result));
}
/// <summary>
/// The whole invocation output (stdout + stderr, incl. the help System.CommandLine prints on a
/// parse error) never carries the file content, even when the file was read successfully and
/// another error stopped the command.
/// </summary>
[Fact]
public async Task ParseErrorOutput_NeverContainsFileContent()
{
var path = WriteFile(MxConfig);
var (exit, output) = await InvokeCapturedAsync(
$"data-connection create --name C --protocol MxGateway --primary-config-file {path}"); // --site-id missing
Assert.NotEqual(0, exit);
Assert.Contains("--site-id", output);
Assert.DoesNotContain(Secret, output);
}
/// <summary>A file the server rejects (here: no server) does not echo the content either.</summary>
[Fact]
public async Task SendFailureOutput_NeverContainsFileContent()
{
var path = WriteFile(MxConfig);
var (_, output) = await InvokeCapturedAsync(
$"data-connection create --site-id 1 --name C --protocol MxGateway --primary-config-file {path} --url http://127.0.0.1:1 --username u --password p");
Assert.DoesNotContain(Secret, output);
}
// ---- helpers ----
private static async Task<(int Exit, string Output)> InvokeCapturedAsync(string arguments)
{
var origOut = Console.Out;
var origErr = Console.Error;
var buffer = new StringWriter();
Console.SetOut(buffer);
Console.SetError(buffer);
try
{
var result = CliApplication.BuildRoot().Parse(arguments);
var exit = await result.InvokeAsync(new InvocationConfiguration { Output = buffer, Error = buffer });
return (exit, buffer.ToString());
}
finally
{
Console.SetOut(origOut);
Console.SetError(origErr);
}
}
private static async Task<JsonElement> SendAsync(string arguments)
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync();
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
await reader.ReadLineAsync();
var length = 0;
while (await reader.ReadLineAsync() is { Length: > 0 } header)
if (header.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
length = int.Parse(header.Split(':')[1]);
var buffer = new char[length];
await reader.ReadBlockAsync(buffer, 0, length);
var response = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}");
await stream.WriteAsync(response);
return new string(buffer);
});
var args = arguments.Split(' ')
.Concat(new[] { "--url", $"http://127.0.0.1:{port}", "--username", "test", "--password", "test", "--format", "json" })
.ToArray();
var result = CliApplication.BuildRoot().Parse(args);
Assert.Empty(result.Errors);
Assert.Equal(0, await result.InvokeAsync());
var body = await server.WaitAsync(TimeSpan.FromSeconds(5));
using var doc = JsonDocument.Parse(body);
return doc.RootElement.GetProperty("payload").Clone();
}
finally
{
listener.Stop();
}
}
}