f33f25a10b
Register row 98 — the site-scope authorization gate was FAIL-OPEN on an
unresolvable target. The resolve-then-check shape was written as
`if (target != null) EnforceSiteScope(...)`, so a null resolve skipped the
check entirely and a site-scoped caller passed the gate for any id in the
system. Authorization now fails CLOSED: a target that cannot be PROVEN to sit
inside the caller's scope is treated as outside it.
The defect turned out to have THREE more instances than the register recorded.
Besides `EnforceSiteScopeForInstance` and `EnforceSiteScopeForIdentifier`, the
same inline shape sat in `HandleGetInstance`, `HandleGetSite` and
`HandleGetDataConnection` — read handlers that resolve their own target and so
never call the helpers. Those were the sharper half of the bug: they answered
success-with-null for a nonexistent id and Unauthorized for an out-of-scope
one, making the pair an id-enumeration oracle over the whole fleet. All five
now route through one shared `DenyUnresolvedTargetIfScoped` arm.
Two properties are load-bearing and pinned by tests:
- System-wide callers (Administrator, or a Deployer with no site grants) are
untouched — they return at the guards above the lookup and keep the
handler's ordinary "not found" / null answer.
- The denial is indistinguishable from an out-of-scope denial: same
ManagementUnauthorized shape, and a message that never states whether the
target exists.
Register row 99 — CLI `audit query --status delivered` failed client-side while
the server would have accepted it: `AcceptOnlyFromAmong` matches ordinally
against the capitalised literals. Same treatment row 88 gave `deploy status` —
the client-side gate is removed, the valid set moves into the option help text,
and the server becomes the single authoritative check.
That required ADDING the server check, not just relying on one: the shared
audit filter parser was LAX, silently dropping an unrecognised value and
leaving the dimension unconstrained — so removing the CLI gate alone would have
turned a loud client-side failure into a plausible, complete-looking page that
did not answer the question asked. New `ParseEnumListStrict` (case-insensitive,
rejects ordinals — `Enum.TryParse` accepts "3", and a filter is a name-based
contract) backs the ManagementService /api/audit/query + /api/audit/export
endpoints, which now return 400 BAD_REQUEST naming the valid set.
Other AcceptOnlyFromAmong sites surveyed in the audit group: `--channel`,
`--kind`, `--status` on BOTH `query` and `export` (6 uses) plus `export
--format` (rejected `--format CSV` though the server lowercases it) — all seven
fixed here. Deliberately left alone: `template folder reorder --direction`
(pure client-side literal, no server-side parse to defer to) and the global
`--format json|table` (CLI-local output selection, never sent to a server).
The CentralUI AuditLogPage drill-in parser and the CentralUI export endpoint it
feeds deliberately KEEP the lax parse — they read a query string a human may
have bookmarked, where a stale fragment must still render the page. Both
contracts now live side by side in AuditQueryParamParsers and are documented as
non-reconcilable.
Docs: Component-ManagementService.md gains a "Site-scope gate: fail-closed on
an unresolvable target" section and a strict-filter note on the audit
endpoints; Component-Security.md records the fail-closed shape under Deployer
site scoping; the CLI README marks the audit filter values case-insensitive and
states the no-silent-drop / no-ordinals contract.
Tests: 5 new ManagementActorTests fail before the row-98 fix (verified by
reverting ManagementActor.cs) and cover scoped+nonexistent -> denied,
system-wide+nonexistent -> unchanged (both Administrator and no-grant Deployer),
scoped+in-scope -> allowed, and the indistinguishability property. 21 CLI tests
fail before the row-99 fix. Four pre-existing tests that pinned the removed
client-side gate were rewritten to pin the new contract.
380 lines
14 KiB
C#
380 lines
14 KiB
C#
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_IsNotRejectedAtTheParseLayer()
|
|
{
|
|
// Register row 99: the client-side AcceptOnlyFromAmong gate is gone. It
|
|
// matched case-SENSITIVELY, so it rejected `--format CSV` even though the
|
|
// server lowercases the value before matching. The server returns a 400
|
|
// naming csv/jsonl/parquet and is the authoritative check.
|
|
var root = AuditCommandTestHarness.BuildRoot();
|
|
var parse = root.Parse(new[]
|
|
{
|
|
"audit", "export", "--since", "1h", "--until", "0h",
|
|
"--format", "xml", "--output", "/tmp/out.xml",
|
|
});
|
|
Assert.Empty(parse.Errors);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("CSV")]
|
|
[InlineData("Csv")]
|
|
[InlineData("JSONL")]
|
|
public void Export_FormatInAnyCase_IsAccepted(string format)
|
|
{
|
|
var root = AuditCommandTestHarness.BuildRoot();
|
|
var parse = root.Parse(new[]
|
|
{
|
|
"audit", "export", "--since", "1h", "--until", "0h",
|
|
"--format", format, "--output", "/tmp/out.csv",
|
|
});
|
|
Assert.Empty(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_WithOneUnrecognisedName_StillParses()
|
|
{
|
|
// Register row 99: the vocabulary gate moved server-side; the multi-value
|
|
// shape itself is unchanged, so both values reach the server to adjudicate.
|
|
var root = AuditCommandTestHarness.BuildRoot();
|
|
var parse = root.Parse(new[]
|
|
{
|
|
"audit", "export", "--since", "1h", "--until", "0h",
|
|
"--format", "csv", "--output", "/tmp/out.csv",
|
|
"--channel", "ApiOutbound", "OutboundApi",
|
|
});
|
|
Assert.Empty(parse.Errors);
|
|
}
|
|
|
|
// ---- 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();
|
|
}
|
|
}
|