fc81cbbd36
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
231 lines
8.9 KiB
C#
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.
|
|
}
|
|
}
|
|
}
|
|
}
|