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(); } } }