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;
///
/// ScadaBridge#137: secret-bearing options take their value from a file (--x-file path) or
/// stdin (--x-file -) 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.
///
[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 _origReader = ValueSourceOptions.StdinReader;
private readonly Func _origRedirected = ValueSourceOptions.StdinIsRedirected;
/// Restores the stdin hooks and deletes the temp directory.
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 ----
/// The primary config is read from a file into the payload.
[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());
}
/// '-' reads the value from stdin.
[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());
}
/// The inline form keeps working, and file + inline can mix across different options.
[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());
}
/// Only one trailing line break is stripped; inner whitespace is kept.
[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());
}
/// External-system auth config from a file.
[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());
}
/// The SMS create requirement is satisfied by --auth-token-file.
[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());
}
/// SMTP credentials from a file on update.
[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());
}
/// --password-file wins over the environment fallback and reaches the resolver.
[Fact]
public void PasswordFile_ResolvesPassword()
{
var path = WriteFile(Secret + "\n");
var root = CliApplication.BuildRoot();
var passwordOption = (Option)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 ----
/// A missing file is a parse error naming the option and path.
[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);
}
/// A directory path is a read error, not a crash.
[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));
}
/// An empty file is refused.
[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));
}
/// '-' on a terminal stdin is refused instead of blocking.
[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));
}
/// Inline + file for the same value is refused — and the error does not echo the file.
[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);
}
/// Two options cannot both read stdin.
[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));
}
/// --password and --password-file together are refused.
[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));
}
/// The connection string stays required — either form satisfies it.
[Fact]
public void DbConnection_NeitherForm_IsRequiredError()
{
var result = CliApplication.BuildRoot().Parse("db-connection create --name Db");
Assert.Contains("--connection-string", AllErrors(result));
}
///
/// 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.
///
[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);
}
/// A file the server rejects (here: no server) does not echo the content either.
[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 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();
}
}
}