using System.Diagnostics; using System.Security.Cryptography; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Cli; namespace ZB.MOM.WW.Secrets.Tests.Cli; /// /// scadaproj#35 (Secrets-017): secret set/rotate take the value from stdin or a masked /// prompt; the positional form still works but warns; and argv never reaches configuration. /// public sealed class SetCommandLineTests { private static string NoPrompt(string label) => throw new InvalidOperationException("the prompt must not be used here"); // ---------- parsing ---------- [Fact] public void A_bare_name_reads_the_value_implicitly() { SetCommandLine line = SetCommandLine.Parse(["set", "db/pw", "--content-type", "json"]); Assert.Equal("db/pw", line.Name); Assert.Equal(SecretValueSource.Implicit, line.Source); Assert.Null(line.ArgumentValue); Assert.Equal(SecretContentType.Json, line.ContentType); } [Theory] [InlineData("-")] [InlineData("--value-stdin")] public void Dash_and_the_flag_both_select_stdin(string marker) { SetCommandLine line = SetCommandLine.Parse(["rotate", "db/pw", marker, "--description", "d"]); Assert.Equal(SecretValueSource.Stdin, line.Source); Assert.Equal("d", line.Description); } [Fact] public void The_positional_value_still_parses_for_compatibility() { SetCommandLine line = SetCommandLine.Parse(["set", "db/pw", "hunter2", "--content-type", "text"]); Assert.Equal(SecretValueSource.Argument, line.Source); Assert.Equal("hunter2", line.ArgumentValue); } [Fact] public void A_positional_value_and_the_stdin_flag_conflict() { Assert.Throws(() => SetCommandLine.Parse(["set", "db/pw", "v", "--value-stdin"])); } [Fact] public void An_unexpected_argument_is_rejected_without_echoing_it() { ArgumentException ex = Assert.Throws( () => SetCommandLine.Parse(["set", "db/pw", "-", "Hunter2Secret"])); Assert.DoesNotContain("Hunter2Secret", ex.Message, StringComparison.Ordinal); } // ---------- value acquisition ---------- [Fact] public void The_positional_value_is_used_but_warns() { var stderr = new StringWriter(); SetCommandLine line = SetCommandLine.Parse(["set", "db/pw", "hunter2"]); string value = line.ReadValue(TextReader.Null, stdinRedirected: false, NoPrompt, stderr); Assert.Equal("hunter2", value); Assert.Contains("warning:", stderr.ToString(), StringComparison.Ordinal); Assert.DoesNotContain("hunter2", stderr.ToString(), StringComparison.Ordinal); } [Theory] [InlineData("s3cret\n", "s3cret")] [InlineData("s3cret\r\n", "s3cret")] [InlineData("s3cret", "s3cret")] [InlineData(" two lines\nkept \n", " two lines\nkept ")] public void Stdin_drops_exactly_one_trailing_newline(string piped, string expected) { SetCommandLine line = SetCommandLine.Parse(["set", "db/pw", "-"]); string value = line.ReadValue(new StringReader(piped), stdinRedirected: true, NoPrompt, TextWriter.Null); Assert.Equal(expected, value); } [Fact] public void Implicit_source_reads_piped_stdin_without_prompting() { SetCommandLine line = SetCommandLine.Parse(["set", "db/pw"]); Assert.Equal("v", line.ReadValue(new StringReader("v\n"), stdinRedirected: true, NoPrompt, TextWriter.Null)); } [Fact] public void Implicit_source_at_a_terminal_prompts_twice_and_requires_a_match() { SetCommandLine line = SetCommandLine.Parse(["set", "db/pw"]); var answers = new Queue(["typed", "typed", "typed", "typo"]); var labels = new List(); string Prompt(string label) { labels.Add(label); return answers.Dequeue(); } Assert.Equal("typed", line.ReadValue(TextReader.Null, stdinRedirected: false, Prompt, TextWriter.Null)); Assert.Equal(2, labels.Count); Assert.Throws( () => line.ReadValue(TextReader.Null, stdinRedirected: false, Prompt, TextWriter.Null)); } [Fact] public void Empty_stdin_is_refused() { SetCommandLine line = SetCommandLine.Parse(["set", "db/pw", "--value-stdin"]); Assert.Throws( () => line.ReadValue(new StringReader("\n"), stdinRedirected: true, NoPrompt, TextWriter.Null)); } // ---------- the real executable ---------- [Fact] public async Task A_dash_prefixed_positional_value_no_longer_crashes_the_host_or_leaks() { // THE REGRESSION. Before the fix the CLI handed argv to Host.CreateApplicationBuilder, whose // command-line configuration provider threw an unhandled FormatException for "-p=..." with the // whole argument — the secret — in the message on stderr. using var rig = new CliRig(); CliResult set = await rig.RunAsync(["set", "db/pw", "-p=Hunter2Secret"]); Assert.DoesNotContain("Hunter2Secret", set.Stderr, StringComparison.Ordinal); Assert.DoesNotContain("FormatException", set.Stderr, StringComparison.Ordinal); Assert.Equal(0, set.ExitCode); Assert.Contains("warning:", set.Stderr, StringComparison.Ordinal); CliResult get = await rig.RunAsync(["get", "db/pw"]); Assert.Equal("-p=Hunter2Secret", get.Stdout.Trim()); } [Fact] public async Task A_value_piped_on_stdin_is_stored() { using var rig = new CliRig(); CliResult set = await rig.RunAsync(["set", "db/pw", "--value-stdin"], stdin: "pw=with=equals\n"); Assert.Equal(0, set.ExitCode); Assert.DoesNotContain("warning:", set.Stderr, StringComparison.Ordinal); CliResult rotate = await rig.RunAsync(["rotate", "db/pw"], stdin: "rotated\n"); Assert.Equal(0, rotate.ExitCode); CliResult get = await rig.RunAsync(["get", "db/pw"]); Assert.Equal("rotated", get.Stdout.Trim()); } private sealed record CliResult(int ExitCode, string Stdout, string Stderr); // Runs the built CLI against a throwaway store with a throwaway key, from an empty working // directory so no stray appsettings.json is picked up. private sealed class CliRig : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), $"zb-cli-{Guid.NewGuid():N}"); private readonly string _key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); // The CLI's host takes its working directory as the content root, and the store must sit // OUTSIDE the content root. The store used to live under the working directory; the old // lexical path rule missed that on macOS only because the temp path is spelled /var/... while // the working directory reports /private/var/... (Secrets-005 resolves the symlink). private string WorkDir => Path.Combine(_dir, "work"); public CliRig() => Directory.CreateDirectory(Path.Combine(_dir, "work")); public async Task RunAsync(string[] args, string? stdin = null) { string dll = Path.Combine(AppContext.BaseDirectory, "ZB.MOM.WW.Secrets.Cli.dll"); var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") { WorkingDirectory = WorkDir, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; psi.ArgumentList.Add(dll); foreach (string arg in args) { psi.ArgumentList.Add(arg); } psi.Environment["Secrets__SqlitePath"] = Path.Combine(_dir, "store", "secrets.db"); psi.Environment["Secrets__MasterKey__Source"] = "Environment"; psi.Environment["Secrets__MasterKey__EnvVarName"] = "ZB_SECRETS_CLI_TEST_KEY"; psi.Environment["ZB_SECRETS_CLI_TEST_KEY"] = _key; using Process process = Process.Start(psi)!; if (stdin is not null) { await process.StandardInput.WriteAsync(stdin); } process.StandardInput.Close(); Task stdout = process.StandardOutput.ReadToEndAsync(); Task stderr = process.StandardError.ReadToEndAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); await process.WaitForExitAsync(timeout.Token); return new CliResult(process.ExitCode, await stdout, await stderr); } public void Dispose() { try { Directory.Delete(_dir, recursive: true); } catch (IOException) { // Best effort: a child process may still be releasing the SQLite file on Windows. } } } }