Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SetCommandLineTests.cs
T
Joseph Doherty fc81cbbd36 test(secrets-cli): CLI rig keeps the store outside the working directory (scadaproj#45)
The CLI host takes its working directory as the content root, and the rig put the store under it.
The old lexical store-path rule missed that on macOS only because the temp path is spelled
/var/... while the working directory reports /private/var/...; with Secrets-005's link resolution
the rule (correctly) refused it. The rig now runs from a sibling work/ directory.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00

231 lines
8.9 KiB
C#

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;
/// <summary>
/// scadaproj#35 (Secrets-017): <c>secret set</c>/<c>rotate</c> take the value from stdin or a masked
/// prompt; the positional form still works but warns; and argv never reaches configuration.
/// </summary>
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<ArgumentException>(() => SetCommandLine.Parse(["set", "db/pw", "v", "--value-stdin"]));
}
[Fact]
public void An_unexpected_argument_is_rejected_without_echoing_it()
{
ArgumentException ex = Assert.Throws<ArgumentException>(
() => 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<string>(["typed", "typed", "typed", "typo"]);
var labels = new List<string>();
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<ArgumentException>(
() => 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<ArgumentException>(
() => 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<CliResult> 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<string> stdout = process.StandardOutput.ReadToEndAsync();
Task<string> 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.
}
}
}
}