refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,62 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Shared helpers for invoking the <c>audit</c> command tree in tests and capturing
/// stdout/stderr/exit code.
/// </summary>
internal static class AuditCommandTestHarness
{
public static RootCommand BuildRoot()
{
var url = new Option<string>("--url") { Recursive = true };
var username = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
var format = CliOptions.CreateFormatOption();
var root = new RootCommand();
root.Add(url);
root.Add(username);
root.Add(password);
root.Add(format);
root.Add(AuditCommands.Build(url, format, username, password));
return root;
}
/// <summary>
/// Parses and invokes the command tree, capturing output from both channels the CLI
/// uses: System.CommandLine's parser diagnostics flow through the
/// <see cref="InvocationConfiguration"/> writers, while command actions write through
/// <see cref="Console"/> (consistent with the rest of the CLI). Both are merged into
/// the returned <c>Out</c>/<c>Err</c> strings. Callers must be in the <c>Console</c>
/// xUnit collection so the global <see cref="Console"/> redirect is not racy.
/// </summary>
public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args)
{
var output = new StringWriter();
var error = new StringWriter();
var originalOut = Console.Out;
var originalErr = Console.Error;
Console.SetOut(output);
Console.SetError(error);
int exit;
try
{
exit = root.Parse(args).Invoke(new InvocationConfiguration
{
Output = output,
Error = error,
});
}
finally
{
Console.SetOut(originalOut);
Console.SetError(originalErr);
}
return (exit, output.ToString(), error.ToString());
}
}
@@ -0,0 +1,61 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Scaffold tests for the <c>scadabridge audit</c> command group (Audit Log #23 M8-T1).
/// Verifies the parent command exists with its three subcommands and that every leaf
/// has an action wired.
/// </summary>
public class AuditCommandsScaffoldTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
private static readonly Option<string> Username = new("--username") { Recursive = true };
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
private static Command BuildAudit()
=> AuditCommands.Build(Url, Format, Username, Password);
[Fact]
public void Audit_Command_IsNamedAudit()
{
var audit = BuildAudit();
Assert.Equal("audit", audit.Name);
Assert.False(string.IsNullOrWhiteSpace(audit.Description));
}
[Fact]
public void Audit_HasThreeSubcommands_QueryExportVerifyChain()
{
var audit = BuildAudit();
var names = audit.Subcommands.Select(c => c.Name).OrderBy(n => n).ToArray();
Assert.Equal(new[] { "export", "query", "verify-chain" }, names);
}
[Fact]
public void Audit_HelpText_ListsAllSubcommands()
{
var root = new RootCommand();
root.Add(BuildAudit());
var output = new StringWriter();
var exit = root.Parse(new[] { "audit", "--help" })
.Invoke(new InvocationConfiguration { Output = output });
Assert.Equal(0, exit);
var text = output.ToString();
Assert.Contains("query", text);
Assert.Contains("export", text);
Assert.Contains("verify-chain", text);
}
[Fact]
public void Audit_EveryLeafCommand_HasAnAction()
{
var audit = BuildAudit();
Assert.All(audit.Subcommands, sub =>
Assert.True(sub.Action != null, $"Leaf command '{sub.Name}' has no action."));
}
}
@@ -0,0 +1,100 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Tests for the <c>audit-log</c> → <c>audit-config</c> rename (Audit Log #23 M8-T7):
/// the new name parses, the deprecated <c>audit-log</c> alias still resolves the full
/// subcommand tree and emits a stderr deprecation warning, and the renamed group does
/// not collide with the distinct <c>audit</c> group from Bundle A.
/// </summary>
public class AuditConfigDeprecationTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
private static readonly Option<string> Username = new("--username") { Recursive = true };
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
private static RootCommand BuildRoot()
{
var root = new RootCommand();
root.Add(Url);
root.Add(Username);
root.Add(Password);
root.Add(Format);
root.Add(AuditCommands.Build(Url, Format, Username, Password));
root.Add(AuditLogCommands.Build(Url, Format, Username, Password));
return root;
}
[Fact]
public void AuditConfig_Query_Works()
{
// The new `audit-config query` name parses cleanly with no errors.
var root = BuildRoot();
var parse = root.Parse(new[] { "audit-config", "query", "--user", "alice" });
Assert.Empty(parse.Errors);
}
[Fact]
public void AuditLog_Query_StillWorks_ButEmitsDeprecationWarning_ToStderr()
{
// The deprecated `audit-log` alias still resolves the full subcommand tree...
var root = BuildRoot();
var parse = root.Parse(new[] { "audit-log", "query", "--user", "alice" });
Assert.Empty(parse.Errors);
// ...and invoking via the old name emits the deprecation warning to stderr.
var stderr = new StringWriter();
AuditLogCommands.WriteDeprecationWarningIfNeeded(
new[] { "audit-log", "query" }, stderr);
var warning = stderr.ToString();
Assert.Contains("deprecated", warning);
Assert.Contains("audit-config", warning);
}
[Fact]
public void DeprecationWarning_NotEmitted_ForNewName()
{
// The new `audit-config` name must not trigger the deprecation warning.
var stderr = new StringWriter();
AuditLogCommands.WriteDeprecationWarningIfNeeded(
new[] { "audit-config", "query" }, stderr);
Assert.Equal("", stderr.ToString());
}
[Fact]
public void DeprecationWarning_NotEmitted_ForUnrelatedCommand()
{
var stderr = new StringWriter();
AuditLogCommands.WriteDeprecationWarningIfNeeded(
new[] { "audit", "query" }, stderr);
Assert.Equal("", stderr.ToString());
}
[Fact]
public void Audit_And_AuditConfig_AreDistinctCommands_NoConflict()
{
var root = BuildRoot();
var auditNames = new[] { "audit", "audit-config" };
foreach (var name in auditNames)
{
var match = root.Subcommands.SingleOrDefault(c => c.Name == name);
Assert.NotNull(match);
}
// The two groups are distinct objects with distinct subcommand sets:
// `audit` has query/export/verify-chain; `audit-config` has only query.
var audit = root.Subcommands.Single(c => c.Name == "audit");
var auditConfig = root.Subcommands.Single(c => c.Name == "audit-config");
Assert.NotSame(audit, auditConfig);
Assert.Contains(audit.Subcommands, c => c.Name == "verify-chain");
Assert.DoesNotContain(auditConfig.Subcommands, c => c.Name == "verify-chain");
// `audit-config` carries the deprecated `audit-log` alias; `audit` does not.
Assert.Contains(AuditLogCommands.DeprecatedAlias, auditConfig.Aliases);
Assert.DoesNotContain(AuditLogCommands.DeprecatedAlias, audit.Aliases);
}
}
@@ -0,0 +1,358 @@
using System.Net;
using System.Text;
using System.Web;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Tests for the <c>scadabridge audit export</c> subcommand (Audit Log #23 M8-T3):
/// required-flag enforcement, query-string construction, streaming the response body
/// to the output file, and the parquet-not-implemented (501) path.
/// </summary>
[Collection("Console")]
public class AuditExportCommandTests
{
// ---- CLI parsing: required flags --------------------------------------
[Fact]
public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit()
{
var root = AuditCommandTestHarness.BuildRoot();
// --output is omitted.
var (exit, _, err) = AuditCommandTestHarness.Invoke(
root, "audit", "export", "--since", "1h", "--until", "0h", "--format", "csv");
Assert.NotEqual(0, exit);
Assert.Contains("--output", err);
}
[Fact]
public void Export_AllRequiredFlagsPresent_ParsesWithoutError()
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Export_InvalidFormat_Rejected()
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "export", "--since", "1h", "--until", "0h",
"--format", "xml", "--output", "/tmp/out.xml",
});
Assert.NotEmpty(parse.Errors);
}
// ---- Query string -----------------------------------------------------
[Fact]
public void BuildQueryString_IncludesWindowFormatAndFilters()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var args = new AuditExportArgs
{
Since = "1h",
Until = "2026-05-20T12:00:00Z",
Format = "jsonl",
Output = "/tmp/x",
Channel = new[] { "Notification" },
Site = new[] { "site-9" },
};
var qs = AuditExportHelpers.BuildQueryString(args, now);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("jsonl", parsed["format"]);
Assert.Equal("Notification", parsed["channel"]);
Assert.Equal("site-9", parsed["sourceSiteId"]);
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
}
[Fact]
public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var args = new AuditExportArgs
{
Since = "1h",
Until = "2026-05-20T12:00:00Z",
Format = "csv",
Output = "/tmp/x",
Channel = new[] { "ApiOutbound", "DbOutbound" },
Kind = new[] { "ApiCall", "DbWrite" },
Status = new[] { "Failed", "Parked" },
Site = new[] { "site-1", "site-2" },
};
var qs = AuditExportHelpers.BuildQueryString(args, now);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel"));
Assert.Equal(new[] { "ApiCall", "DbWrite" }, parsed.GetValues("kind"));
Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status"));
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
}
[Fact]
public void BuildQueryString_OmitsUnsetMultiValueFilters()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var args = new AuditExportArgs
{
Since = "1h",
Until = "0h",
Format = "csv",
Output = "/tmp/x",
};
var qs = AuditExportHelpers.BuildQueryString(args, now);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Null(parsed["channel"]);
Assert.Null(parsed["kind"]);
Assert.Null(parsed["status"]);
Assert.Null(parsed["sourceSiteId"]);
}
[Fact]
public void Export_MultipleChannelValues_SingleToken_AreAccepted()
{
// AllowMultipleArgumentsPerToken: --channel A B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
"--channel", "ApiOutbound", "DbOutbound",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted()
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
"--channel", "ApiOutbound", "--channel", "Notification",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Export_MultiValueChannel_WithOneInvalidName_FailsFast()
{
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(
root, "audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
"--channel", "ApiOutbound", "OutboundApi");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
// ---- Streaming export to file -----------------------------------------
private sealed class BodyHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly Func<HttpContent> _content;
public string? RequestPathAndQuery { get; private set; }
public BodyHandler(HttpStatusCode status, Func<HttpContent> content)
{
_status = status;
_content = content;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestPathAndQuery = request.RequestUri!.PathAndQuery;
return Task.FromResult(new HttpResponseMessage(_status) { Content = _content() });
}
}
[Fact]
public async Task RunExport_Success_StreamsResponseToOutputFile()
{
var path = Path.Combine(Path.GetTempPath(), $"audit-export-{Guid.NewGuid():N}.jsonl");
try
{
var handler = new BodyHandler(HttpStatusCode.OK,
() => new StringContent("{\"eventId\":\"e1\"}\n{\"eventId\":\"e2\"}\n",
Encoding.UTF8, "application/x-ndjson"));
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditExportHelpers.RunExportAsync(
client,
new AuditExportArgs { Since = "1h", Until = "0h", Format = "jsonl", Output = path },
output, DateTimeOffset.UtcNow);
Assert.Equal(0, exit);
Assert.True(File.Exists(path));
var content = await File.ReadAllTextAsync(path);
Assert.Contains("e1", content);
Assert.Contains("e2", content);
Assert.Contains("api/audit/export", handler.RequestPathAndQuery);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public async Task RunExport_Http403_ReturnsExitCode2()
{
// CLI-018: an HTTP 403 on /api/audit/export must produce exit code 2 per the
// documented CLI contract — the legacy bare-1 return masked auth failures
// as generic command failures.
var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv");
try
{
var handler = new BodyHandler(HttpStatusCode.Forbidden,
() => new StringContent("{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}", Encoding.UTF8, "application/json"));
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditExportHelpers.RunExportAsync(
client,
new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path },
output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
Assert.False(File.Exists(path));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2()
{
var path = Path.Combine(Path.GetTempPath(), $"audit-export-401-{Guid.NewGuid():N}.csv");
try
{
var handler = new BodyHandler(HttpStatusCode.BadRequest,
() => new StringContent("{\"error\":\"nope\",\"code\":\"FORBIDDEN\"}", Encoding.UTF8, "application/json"));
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditExportHelpers.RunExportAsync(
client,
new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path },
output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
{
var path = Path.Combine(Path.GetTempPath(), $"audit-export-{Guid.NewGuid():N}.parquet");
try
{
var handler = new BodyHandler(HttpStatusCode.NotImplemented,
() => new StringContent("Parquet export is not yet supported.", Encoding.UTF8, "text/plain"));
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditExportHelpers.RunExportAsync(
client,
new AuditExportArgs { Since = "1h", Until = "0h", Format = "parquet", Output = path },
output, DateTimeOffset.UtcNow);
Assert.NotEqual(0, exit);
// No file should be written on the 501 path.
Assert.False(File.Exists(path));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered()
{
// A ~8 MB body delivered via a streaming HttpContent. The export must copy it to
// disk via Stream.CopyToAsync (chunked) — assert the file is written in full and
// matches, which proves the streaming copy path works for multi-MB payloads.
var path = Path.Combine(Path.GetTempPath(), $"audit-export-big-{Guid.NewGuid():N}.csv");
const int totalBytes = 8 * 1024 * 1024;
try
{
var handler = new BodyHandler(HttpStatusCode.OK,
() => new StreamContent(new RepeatingStream((byte)'a', totalBytes)));
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditExportHelpers.RunExportAsync(
client,
new AuditExportArgs { Since = "7d", Until = "0h", Format = "csv", Output = path },
output, DateTimeOffset.UtcNow);
Assert.Equal(0, exit);
Assert.Equal(totalBytes, new FileInfo(path).Length);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
/// <summary>
/// A read-only stream that yields <paramref name="length"/> copies of a single byte
/// without ever materialising the whole payload — used to simulate a large export
/// body so the streaming copy can be exercised without an 8 MB literal.
/// </summary>
private sealed class RepeatingStream : Stream
{
private readonly byte _value;
private long _remaining;
public RepeatingStream(byte value, long length)
{
_value = value;
_remaining = length;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override int Read(byte[] buffer, int offset, int count)
{
if (_remaining <= 0) return 0;
var n = (int)Math.Min(count, _remaining);
for (var i = 0; i < n; i++) buffer[offset + i] = _value;
_remaining -= n;
return n;
}
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
@@ -0,0 +1,466 @@
using System.Collections.Specialized;
using System.Net;
using System.Text;
using System.Web;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Tests for the <c>scadabridge audit query</c> subcommand (Audit Log #23 M8-T2):
/// time-spec resolution, query-string construction, formatter selection, error
/// handling, and keyset-cursor paging via <c>--all</c>.
/// </summary>
[Collection("Console")]
public class AuditQueryCommandTests
{
// ---- Time-spec parsing -------------------------------------------------
[Fact]
public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var resolved = AuditQueryHelpers.ResolveTimeSpec("1h", now);
Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved);
}
[Fact]
public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var resolved = AuditQueryHelpers.ResolveTimeSpec("7d", now);
Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved);
}
[Fact]
public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var resolved = AuditQueryHelpers.ResolveTimeSpec("2026-01-02T03:04:05Z", now);
Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved);
}
[Fact]
public void ResolveTimeSpec_Garbage_Throws()
{
var now = DateTimeOffset.UtcNow;
Assert.Throws<FormatException>(() => AuditQueryHelpers.ResolveTimeSpec("not-a-time", now));
}
// ---- Query string construction ----------------------------------------
[Fact]
public void BuildQueryString_FullFlagSet_ProducesExpectedParameters()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var args = new AuditQueryArgs
{
Since = "1h",
Until = "2026-05-20T12:00:00Z",
Channel = new[] { "ApiOutbound" },
Kind = new[] { "ApiCallCached" },
Status = new[] { "Delivered" },
Site = new[] { "site-1" },
Target = "weather-api",
Actor = "multi-role",
CorrelationId = "abc-123",
ExecutionId = "def-456",
ParentExecutionId = "ghi-789",
ErrorsOnly = false,
PageSize = 250,
};
var qs = AuditQueryHelpers.BuildQueryString(args, now, afterOccurredAtUtc: null, afterEventId: null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("ApiOutbound", parsed["channel"]);
Assert.Equal("ApiCallCached", parsed["kind"]);
Assert.Equal("Delivered", parsed["status"]);
Assert.Equal("site-1", parsed["sourceSiteId"]);
// The CLI audit query has no --instance flag, so no instance param is emitted.
Assert.Null(parsed["instance"]);
Assert.Equal("weather-api", parsed["target"]);
Assert.Equal("multi-role", parsed["actor"]);
Assert.Equal("abc-123", parsed["correlationId"]);
Assert.Equal("def-456", parsed["executionId"]);
Assert.Equal("ghi-789", parsed["parentExecutionId"]);
Assert.Equal("250", parsed["pageSize"]);
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
}
[Fact]
public void BuildQueryString_ErrorsOnly_MapsToFailedStatus()
{
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ErrorsOnly = true };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("Failed", parsed["status"]);
}
[Fact]
public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue()
{
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs
{
Channel = new[] { "ApiOutbound", "DbOutbound" },
Status = new[] { "Failed", "Parked" },
Site = new[] { "site-1", "site-2" },
};
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel"));
Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status"));
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
}
[Fact]
public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues()
{
// --errors-only stays a single-status override: it pins status=Failed and
// supersedes any explicit (multi-value) --status selection.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs
{
ErrorsOnly = true,
Status = new[] { "Delivered", "Parked" },
};
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "Failed" }, parsed.GetValues("status"));
}
[Fact]
public void BuildQueryString_Cursor_AppendsAfterParameters()
{
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs();
var after = DateTimeOffset.Parse("2026-05-20T10:00:00Z");
var qs = AuditQueryHelpers.BuildQueryString(args, now, after, "evt-99");
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("evt-99", parsed["afterEventId"]);
Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]);
}
[Fact]
public void BuildQueryString_OmitsUnsetFilters()
{
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { PageSize = 100 };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Null(parsed["channel"]);
Assert.Null(parsed["status"]);
Assert.Null(parsed["fromUtc"]);
Assert.Null(parsed["correlationId"]);
Assert.Null(parsed["executionId"]);
Assert.Null(parsed["parentExecutionId"]);
Assert.Equal("100", parsed["pageSize"]);
}
[Fact]
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
{
// --execution-id is a single-value Guid filter — mirrors --correlation-id.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ExecutionId = "11111111-1111-1111-1111-111111111111" };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
}
[Fact]
public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter()
{
// --parent-execution-id is a single-value Guid filter — mirrors --execution-id.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ParentExecutionId = "22222222-2222-2222-2222-222222222222" };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("22222222-2222-2222-2222-222222222222", parsed["parentExecutionId"]);
}
// ---- HTTP execution / paging ------------------------------------------
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly Queue<string> _bodies;
public List<string> RequestUris { get; } = new();
public RecordingHandler(params string[] bodies)
{
_bodies = new Queue<string>(bodies);
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestUris.Add(request.RequestUri!.PathAndQuery);
var body = _bodies.Count > 0 ? _bodies.Dequeue() : "{\"events\":[],\"nextCursor\":null}";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
});
}
}
[Fact]
public async Task RunQuery_SinglePage_WritesEventsAsJsonLines()
{
var handler = new RecordingHandler(
"{\"events\":[{\"eventId\":\"e1\"},{\"eventId\":\"e2\"}],\"nextCursor\":null}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs { PageSize = 100 }, fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(0, exit);
var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
Assert.Contains("e1", lines[0]);
Assert.Contains("e2", lines[1]);
Assert.Single(handler.RequestUris);
}
[Fact]
public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages()
{
var handler = new RecordingHandler(
"{\"events\":[{\"eventId\":\"e1\"}],\"nextCursor\":{\"afterOccurredAtUtc\":\"2026-05-20T10:00:00Z\",\"afterEventId\":\"e1\"}}",
"{\"events\":[{\"eventId\":\"e2\"}],\"nextCursor\":null}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs { PageSize = 100 }, fetchAll: true,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(0, exit);
Assert.Equal(2, handler.RequestUris.Count);
Assert.Contains("afterEventId=e1", handler.RequestUris[1]);
var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
}
[Fact]
public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent()
{
var handler = new RecordingHandler(
"{\"events\":[{\"eventId\":\"e1\"}],\"nextCursor\":{\"afterOccurredAtUtc\":\"2026-05-20T10:00:00Z\",\"afterEventId\":\"e1\"}}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs { PageSize = 100 }, fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(0, exit);
Assert.Single(handler.RequestUris);
}
[Fact]
public async Task RunQuery_ServerError_ReturnsNonZeroExit()
{
var handler = new ErrorHandler();
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.NotEqual(0, exit);
}
[Fact]
public async Task RunQuery_Http403_ReturnsExitCode2()
{
// CLI-018: an authorization failure on /api/audit/query (HTTP 403) must
// produce exit code 2 per the documented CLI exit-code contract — the
// legacy bare-1 return masked auth failures as generic command failures.
var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
[Fact]
public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2()
{
// The server may signal authorization failure via the error code on a
// non-403 status (e.g. 400 + code=UNAUTHORIZED). Honour both channels.
var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
[Fact]
public async Task RunQuery_GenericServerError_ReturnsExitCode1()
{
// Authentication / internal errors (non-403, no auth code) must remain
// exit code 1 — exit 2 is reserved for authorization failures.
var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(1, exit);
}
private sealed class ErrorHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("{\"error\":\"boom\",\"code\":\"INTERNAL\"}"),
});
}
private sealed class StatusHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly string _body;
public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
// ---- CLI parsing -------------------------------------------------------
[Fact]
public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit()
{
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--bogus", "x");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
[Fact]
public void Query_FormatTable_IsAccepted()
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", "--format", "table" });
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_ExecutionIdOption_IsAccepted()
{
// --execution-id is a single-value option — mirrors --correlation-id.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--execution-id", "11111111-1111-1111-1111-111111111111",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_ParentExecutionIdOption_IsAccepted()
{
// --parent-execution-id is a single-value option — mirrors --execution-id.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--parent-execution-id", "22222222-2222-2222-2222-222222222222",
});
Assert.Empty(parse.Errors);
}
// ---- Enum-name validation (fast-fail) ----------------------------------
[Fact]
public void Query_ChannelWithRealEnumName_IsAccepted()
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound" });
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_MultipleChannelValues_SingleToken_AreAccepted()
{
// AllowMultipleArgumentsPerToken: --channel A B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound", "DbOutbound" });
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted()
{
// --channel A --channel B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--channel", "ApiOutbound", "--channel", "Notification",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_MultiValueChannel_WithOneInvalidName_FailsFast()
{
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(
root, "audit", "query", "--channel", "ApiOutbound", "OutboundApi");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
[Fact]
public void Query_ChannelWithInvalidName_FailsFast_NonZeroExit()
{
// "OutboundApi" is the old (non-existent) name; the real enum is "ApiOutbound".
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--channel", "OutboundApi");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
Assert.Contains("OutboundApi", err);
}
[Fact]
public void Query_KindWithInvalidName_FailsFast_NonZeroExit()
{
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--kind", "CachedCall");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
[Fact]
public void Query_StatusWithInvalidName_FailsFast_NonZeroExit()
{
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--status", "Bogus");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
}
@@ -0,0 +1,117 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Tests for the <c>table</c> output formatter of the <c>scadabridge audit query</c>
/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the
/// empty-result-set case, and null-actor handling.
/// </summary>
public class AuditTableFormatterTests
{
private static IReadOnlyList<JsonElement> Events(string json)
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.EnumerateArray()
.Select(e => e.Clone())
.ToList();
}
[Fact]
public void Table_RendersHeaderRow_WithExpectedColumns()
{
var formatter = new TableAuditFormatter();
var output = new StringWriter();
formatter.WritePage(Events("[]"), output);
var firstLine = output.ToString()
.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0];
foreach (var col in new[]
{
"OccurredAtUtc", "Channel", "Kind", "Status",
"Target", "Actor", "DurationMs", "HttpStatus",
})
{
Assert.Contains(col, firstLine);
}
}
[Fact]
public void Table_TruncatesLongTarget_WithEllipsis()
{
var formatter = new TableAuditFormatter();
var output = new StringWriter();
var longTarget = new string('x', 200);
formatter.WritePage(
Events($"[{{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," +
$"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"{longTarget}\"," +
$"\"actor\":\"multi-role\"}}]"),
output);
var text = output.ToString();
Assert.Contains("…", text);
// The full untruncated target must not appear verbatim.
Assert.DoesNotContain(longTarget, text);
}
[Fact]
public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage()
{
var formatter = new TableAuditFormatter();
var output = new StringWriter();
formatter.WritePage(Events("[]"), output);
var lines = output.ToString()
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Header only — no data rows. (A header line is always emitted so the
// column shape is visible even with zero results.)
Assert.Single(lines);
Assert.Contains("OccurredAtUtc", lines[0]);
}
[Fact]
public void Table_NullActor_RendersBlank()
{
var formatter = new TableAuditFormatter();
var output = new StringWriter();
formatter.WritePage(
Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"InboundApi\"," +
"\"kind\":\"ApiCall\",\"status\":\"Delivered\",\"target\":\"key-1\"," +
"\"actor\":null}]"),
output);
var lines = output.ToString()
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
// The data row must not contain the literal "null" for the actor column.
Assert.DoesNotContain("null", lines[1]);
Assert.Contains("InboundApi", lines[1]);
}
[Fact]
public void Table_HeaderEmittedOncePerPage_DataRowsAligned()
{
var formatter = new TableAuditFormatter();
var output = new StringWriter();
formatter.WritePage(
Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," +
"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"weather-api\"," +
"\"actor\":\"multi-role\",\"durationMs\":42,\"httpStatus\":200}," +
"{\"occurredAtUtc\":\"2026-05-20T12:01:00Z\",\"channel\":\"Notification\"," +
"\"kind\":\"Send\",\"status\":\"Failed\",\"target\":\"ops-list\"," +
"\"actor\":\"scheduler\",\"durationMs\":7}]"),
output);
var lines = output.ToString()
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(3, lines.Length);
Assert.Contains("weather-api", lines[1]);
Assert.Contains("ops-list", lines[2]);
}
}
@@ -0,0 +1,58 @@
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Tests for the <c>scadabridge audit verify-chain</c> subcommand (Audit Log #23 M8-T4).
/// v1 is a no-op stub: a valid <c>--month</c> prints the documented "not enabled"
/// message and exits 0; a malformed month or a missing <c>--month</c> exits non-zero.
/// </summary>
[Collection("Console")]
public class AuditVerifyChainCommandTests
{
[Fact]
public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage()
{
var root = AuditCommandTestHarness.BuildRoot();
var (exit, output, _) = AuditCommandTestHarness.Invoke(
root, "audit", "verify-chain", "--month", "2026-05");
Assert.Equal(0, exit);
Assert.Contains("Hash-chain tamper-evidence is not enabled", output);
Assert.Contains("Component-AuditLog.md", output);
}
[Fact]
public void VerifyChain_MalformedMonth_ExitsNonZero()
{
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, _) = AuditCommandTestHarness.Invoke(
root, "audit", "verify-chain", "--month", "2026-13");
Assert.NotEqual(0, exit);
}
[Fact]
public void VerifyChain_MissingMonth_ProducesRequiredFlagError()
{
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "verify-chain");
Assert.NotEqual(0, exit);
Assert.Contains("--month", err);
}
[Theory]
[InlineData("2026-05", true)]
[InlineData("2026-01", true)]
[InlineData("2026-12", true)]
[InlineData("2026-13", false)]
[InlineData("2026-00", false)]
[InlineData("2026-5", false)]
[InlineData("not-a-month", false)]
[InlineData("", false)]
public void IsValidMonth_ValidatesYyyyMm(string month, bool expected)
{
Assert.Equal(expected, AuditVerifyChainHelpers.IsValidMonth(month));
}
}
@@ -0,0 +1,94 @@
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// CLI-019 regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>.
/// The pre-fix code did <c>Convert.FromBase64String(...) → File.WriteAllBytes(...)</c>,
/// doubling the bundle's bytes onto the LOH and writing synchronously. The new
/// streaming helper decodes the base64 string in fixed-size chunks straight into
/// a <see cref="FileStream"/>, so peak working set is bounded by the chunk size
/// regardless of how large the bundle is.
/// </summary>
public class BundleCommandsStreamingTests : IDisposable
{
private readonly string _tempPath;
public BundleCommandsStreamingTests()
{
_tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin");
}
public void Dispose()
{
if (File.Exists(_tempPath))
{
File.Delete(_tempPath);
}
}
[Fact]
public void StreamBase64ToFile_SmallPayload_RoundTrips()
{
var bytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var base64 = Convert.ToBase64String(bytes);
var written = BundleCommands.StreamBase64ToFile(base64, _tempPath);
Assert.Equal(bytes.Length, written);
var roundTripped = File.ReadAllBytes(_tempPath);
Assert.Equal(bytes, roundTripped);
}
[Fact]
public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips()
{
// Build a payload several chunks wide so the slicing loop runs more than
// once, with enough trailing bytes that the final slice is short and
// exercises the padding/short-final-chunk path.
var size = (BundleCommands.Base64StreamChunkChars / 4 * 3) * 3 + 17;
var bytes = new byte[size];
for (var i = 0; i < size; i++) bytes[i] = (byte)(i & 0xFF);
var base64 = Convert.ToBase64String(bytes);
var written = BundleCommands.StreamBase64ToFile(base64, _tempPath);
Assert.Equal(size, written);
var roundTripped = File.ReadAllBytes(_tempPath);
Assert.Equal(bytes, roundTripped);
}
[Fact]
public void StreamBase64ToFile_EmptyString_WritesEmptyFile()
{
var written = BundleCommands.StreamBase64ToFile(string.Empty, _tempPath);
Assert.Equal(0, written);
Assert.True(File.Exists(_tempPath));
Assert.Empty(File.ReadAllBytes(_tempPath));
}
[Fact]
public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException()
{
// '*' is not a valid base64 character, so TryFromBase64Chars returns
// false and the helper throws — the pre-fix code threw FormatException
// from Convert.FromBase64String, so the contract is preserved.
var invalid = "this is not valid base64 !!!*";
Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath));
}
[Fact]
public void StreamBase64ToFile_NullBase64_Throws()
{
Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath));
}
[Fact]
public void StreamBase64ToFile_EmptyOutputPath_Throws()
{
Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty));
}
}
@@ -0,0 +1,104 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Tests for the <c>scadabridge notification smtp update</c> subcommand. The command
/// gained two optional flags — <c>--tls-mode</c> and <c>--credentials</c> — that plumb
/// through to <see cref="UpdateSmtpConfigCommand"/>. These tests pin that the flags
/// parse, are genuinely optional (non-breaking), and that <c>--tls-mode</c> rejects
/// values outside the canonical {None, StartTLS, SSL} set.
/// </summary>
public class SmtpUpdateCommandTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
private static readonly Option<string> Username = new("--username") { Recursive = true };
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
private static Command SmtpUpdateCommand()
{
var notification = NotificationCommands.Build(Url, Format, Username, Password);
var smtp = notification.Subcommands.Single(c => c.Name == "smtp");
return smtp.Subcommands.Single(c => c.Name == "update");
}
private static ParseResult ParseUpdate(params string[] args)
=> SmtpUpdateCommand().Parse(args);
[Fact]
public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem()
{
var parse = ParseUpdate(
"--id", "1", "--server", "smtp.example.com", "--port", "587",
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
"--tls-mode", "None", "--credentials", "user:pass");
Assert.Empty(parse.Errors);
var cmd = NotificationCommands.BuildUpdateSmtpConfigCommand(parse);
Assert.Equal(1, cmd.SmtpConfigId);
Assert.Equal("smtp.example.com", cmd.Server);
Assert.Equal(587, cmd.Port);
Assert.Equal("Basic", cmd.AuthMode);
Assert.Equal("noreply@example.com", cmd.FromAddress);
Assert.Equal("None", cmd.TlsMode);
Assert.Equal("user:pass", cmd.Credentials);
}
[Fact]
public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls()
{
var parse = ParseUpdate(
"--id", "2", "--server", "smtp.example.com", "--port", "25",
"--auth-mode", "OAuth2", "--from-address", "noreply@example.com");
Assert.Empty(parse.Errors);
var cmd = NotificationCommands.BuildUpdateSmtpConfigCommand(parse);
Assert.Equal(2, cmd.SmtpConfigId);
Assert.Null(cmd.TlsMode);
Assert.Null(cmd.Credentials);
}
[Theory]
[InlineData("None")]
[InlineData("StartTLS")]
[InlineData("SSL")]
public void Update_TlsModeOption_AcceptsCanonicalValues(string value)
{
var parse = ParseUpdate(
"--id", "1", "--server", "smtp.example.com", "--port", "587",
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
"--tls-mode", value);
Assert.Empty(parse.Errors);
}
[Theory]
[InlineData("Bogus")]
[InlineData("tls")]
[InlineData("none")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling
public void Update_TlsModeOption_RejectsValuesOutsideCanonicalSet(string value)
{
var parse = ParseUpdate(
"--id", "1", "--server", "smtp.example.com", "--port", "587",
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
"--tls-mode", value);
Assert.NotEmpty(parse.Errors);
}
[Fact]
public void Update_TlsModeAndCredentials_AreNotRequired()
{
var update = SmtpUpdateCommand();
var tls = update.Options.Single(o => o.Name == "--tls-mode");
var creds = update.Options.Single(o => o.Name == "--credentials");
Assert.False(tls.Required, "--tls-mode must be optional (preserve-if-omitted).");
Assert.False(creds.Required, "--credentials must be optional (preserve-if-omitted).");
}
}