Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs
T
Joseph Doherty e2f433e849 feat(cli): read secret-bearing option values from a file or stdin (#137)
Every CLI option that can carry a secret gets a `--<name>-file <path|->`
sibling ('-' reads stdin), so the value stays out of the process list and
shell history: --password-file (global), --primary-config-file /
--backup-config-file (data-connection create|update), --auth-config-file
(external-system create|update), --connection-string-file (db-connection
create|update), --credentials-file (notification smtp create|update) and
--auth-token-file (notification sms create|update). Follows the existing
--config-file convention; `@path` is avoided because System.CommandLine
expands @-tokens as response files.

The value is read at parse time; a missing/unreadable/empty source, or '-'
on a terminal stdin, is a parse error naming option + path only, never the
content. Inline and file forms are mutually exclusive, at most one option
may read stdin, and one trailing line break is stripped. Inline forms keep
working.

docker/seed-sites.sh and docker-env2/seed-sites.sh now write the MxGateway
config (windev gateway key) to a mode-600 temp file removed by an EXIT trap
and pass --primary-config-file; seed-flow-outbound-demo.sh pipes its rig
connection string via --connection-string-file -. CLI README (new "Secrets
from a file or stdin" section + per-command tables), Component-CLI.md and
both docker READMEs updated.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 00:46:17 -05:00

194 lines
10 KiB
C#

using System.Collections.Concurrent;
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Drives the production command tree against a loopback recorder. Every public
/// management contract must actually cross HTTP from a CLI action. A constructor
/// reference, an unregistered group, or a help-only leaf cannot satisfy this gate.
/// Server replies are deliberate errors so no local success handlers write exports.
/// </summary>
[Collection("Console")]
public class ManagementSurfaceParityTests
{
/// <summary>Verifies that every HTTP management contract is sent by the production CLI.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EveryHttpManagementContract_IsSentByTheProductionCli()
{
var sent = await ProbeProductionTree();
var registered = typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace && !t.IsAbstract
&& t.Name.EndsWith("Command", StringComparison.Ordinal))
.Where(t => ManagementCommandRegistry.Resolve(t.Name[..^7]) == t)
.Select(ManagementCommandRegistry.GetCommandName).ToHashSet();
var missing = registered.Except(sent.Keys).Order().ToArray();
Assert.True(missing.Length == 0, "No CLI dispatch for: " + string.Join(", ", missing));
Assert.Empty(sent.Keys.Except(registered));
}
/// <summary>Verifies that removing a production schema group is detected by the parity probe.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RemovingProductionSchemaGroup_IsDetectedByParityProbe()
{
var sent = await ProbeProductionTree(omitGroup: "shared-schema");
Assert.DoesNotContain("ListSharedSchemas", sent.Keys);
Assert.Contains("ListSharedScripts", sent.Keys);
}
private static readonly HashSet<string> AuditManagementLeaves =
["audit verify-chain", "audit verify-heads", "audit archive list", "audit archive verify", "audit archive cleanup"];
private static async Task<ConcurrentDictionary<string, string>> ProbeProductionTree(string? omitGroup = null)
{
var root = CliApplication.BuildRoot();
var recorded = new ConcurrentDictionary<string, string>();
var directory = Directory.CreateTempSubdirectory("cli-parity-");
var file = Path.Combine(directory.FullName, "input.json");
await File.WriteAllTextAsync(file, "{}");
var csv = Path.Combine(directory.FullName, "native.csv");
await File.WriteAllTextAsync(csv, "SourceName,Connection,SourceReference,Filter\nSource,Connection,Path,\n");
// `audit verify-heads` (ScadaBridge#107) reads exported chain heads before it sends anything.
HeadsFile = Path.Combine(directory.FullName, "heads.jsonl");
await File.WriteAllTextAsync(HeadsFile,
"{\"v\":1,\"month\":\"2026-05\",\"lastSeq\":3,\"lastHash\":\"" + new string('a', 64)
+ "\",\"purgedThroughSeq\":null,\"purgedThroughHash\":null,\"chainedRows\":3,\"tombstones\":0,"
+ "\"headUpdatedAtUtc\":\"2026-05-02T00:00:00Z\",\"exportedAtUtc\":\"2026-05-02T00:00:00Z\",\"node\":\"central-a\"}\n");
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var url = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}";
var currentPath = "";
var server = Task.Run(async () =>
{
try
{
while (!stop.IsCancellationRequested)
{
using var client = await listener.AcceptTcpClientAsync(stop.Token);
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var length = 0;
while (await reader.ReadLineAsync(stop.Token) is { Length: > 0 } line)
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
length = int.Parse(line[15..].Trim());
var buffer = new char[length];
var read = 0;
while (read < length)
{
var count = await reader.ReadAsync(buffer.AsMemory(read), stop.Token);
if (count == 0) throw new EndOfStreamException();
read += count;
}
using var body = JsonDocument.Parse(new string(buffer));
recorded[body.RootElement.GetProperty("command").GetString()!] = currentPath;
const string response = "{\"error\":\"parity probe\",\"code\":\"PROBE\"}";
var bytes = Encoding.UTF8.GetBytes($"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {response.Length}\r\nConnection: close\r\n\r\n{response}");
await stream.WriteAsync(bytes, stop.Token);
}
}
catch (OperationCanceledException) when (stop.IsCancellationRequested) { }
}, stop.Token);
try
{
foreach (var (leaf, path) in Leaves(root, []))
{
// Separate REST and persistent SignalR surfaces, not management contracts.
// `audit verify-chain` (VerifyAuditChain, ScadaBridge#41), `audit verify-heads`
// (VerifyAuditChainHeads, ScadaBridge#107) and `audit archive
// list|verify` (ListAuditArchives / VerifyAuditArchive, ScadaBridge#42) are the
// audit leaves that ARE management contracts, so they stay in the probe.
if (path.Length == 0 || (path[0] == "audit" && !AuditManagementLeaves.Contains(string.Join(' ', path)))
|| path[0] == omitGroup
|| string.Join(' ', path) == "debug stream") continue;
currentPath = string.Join(' ', path);
var args = new List<string>(path);
foreach (var option in leaf.Options.Where(o => o.Required))
{
args.Add(option.Name);
args.Add(ValueFor(option, currentPath, file, csv));
}
AddConditionalOptions(args, currentPath);
args.AddRange(["--url", url, "--username", "probe", "--password", "probe", "--format", "json"]);
var parse = root.Parse(args.ToArray());
// Some convenience aliases require richer local files/preflight replies;
// other leaves must still emit every contract. Missing coverage fails above.
if (parse.Errors.Count > 0) continue;
await parse.InvokeAsync().WaitAsync(stop.Token);
}
}
finally
{
await stop.CancelAsync();
listener.Stop();
await server;
directory.Delete(recursive: true);
}
return recorded;
}
private static IEnumerable<(Command Leaf, string[] Path)> Leaves(Command command, string[] path)
{
if (command.Subcommands.Count == 0) yield return (command, path);
foreach (var child in command.Subcommands)
foreach (var entry in Leaves(child, [.. path, child.Name])) yield return entry;
}
private static string HeadsFile = "";
private static string ValueFor(Option option, string path, string file, string csv)
{
if (path == "audit verify-heads" && option.Name == "--path") return HeadsFile;
var type = option.GetType().GetGenericArguments().FirstOrDefault();
type = Nullable.GetUnderlyingType(type ?? typeof(string)) ?? type;
if (type == typeof(int) || type == typeof(long) || type == typeof(double)) return "1";
if (type == typeof(Guid)) return "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
if (type == typeof(bool)) return "true";
if (type?.IsEnum == true) return Enum.GetNames(type)[0];
return option.Name switch
{
"--schema-file" or "--input" or "--config-file" or "--der-file" => file,
"--output" => file + ".out",
"--file" => csv,
"--bindings" => "[[\"Value\",1]]",
"--direction" => "up",
"--overrides" or "--config" => "{}",
"--value-json" => "1",
"--data-type" or "--value-type" => "Int32",
"--trigger-type" => "Periodic",
"--auth-type" or "--auth-mode" => "None",
"--http-method" => "GET",
"--type" => "OpcUa",
"--endpoint-url" => "http://localhost",
"--account-sid" => "AC" + new string('a', 32),
"--from-address" => "sender@example.test",
"--thumbprint" => new string('A', 40),
"--role" => "Designer",
"--month" => "2026-05",
"--code" or "--script" => "return null;",
_ => "fixture"
};
}
private static void AddConditionalOptions(List<string> args, string path)
{
if (path is "notification create" or "notification update")
args.AddRange(["--emails", "recipient@example.test"]);
if (path is "notification sms create" or "notification sms update")
args.AddRange(["--from-number", "+15551234567"]);
if (path == "notification sms create") args.AddRange(["--auth-token", "test-token"]);
// One of --connection-string / --connection-string-file is required (validator, not Required).
if (path is "db-connection create" or "db-connection update")
args.AddRange(["--connection-string", "Server=fixture"]);
if (path == "notification recipient add") args.AddRange(["--email", "recipient@example.test"]);
}
}