Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditQueryCommandTests.cs
T
Joseph Doherty f33f25a10b fix(auth+cli): fail-closed site-scope gate (row 98); case-insensitive audit filter values with a strict server-side check (row 99)
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.
2026-08-17 06:05:29 -04:00

475 lines
19 KiB
C#

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);
}
// ========================================================================
// Register row 99: filter-vocabulary validation moved OFF the CLI.
//
// These four tests used to assert that AcceptOnlyFromAmong rejected an
// unrecognised --channel/--kind/--status at the parse layer. That gate matched
// case-SENSITIVELY, so it also rejected `--status delivered`, which the server
// has always accepted — the CLI was stricter than the system it drives. The
// gate is gone; the parse layer now accepts any string and the server answers
// 400 naming the valid set (AuditEndpointsTests pins that side).
//
// What is asserted here is the CLIENT half of that contract: the value is
// carried through to the request rather than rejected locally.
// ========================================================================
[Theory]
[InlineData("--channel", "OutboundApi")] // old (non-existent) name; the real enum is "ApiOutbound"
[InlineData("--kind", "CachedCall")]
[InlineData("--status", "Bogus")]
public void Query_UnrecognisedFilterValue_IsNotRejectedAtTheParseLayer(string option, string value)
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", option, value });
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_MultiValueChannel_WithOneUnrecognisedName_StillParses()
{
// The multi-value shape is unchanged; only the vocabulary gate is gone, so
// BOTH values reach the server for it to adjudicate.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound", "OutboundApi" });
Assert.Empty(parse.Errors);
}
[Theory]
[InlineData("--channel", "apioutbound")]
[InlineData("--kind", "apicall")]
[InlineData("--status", "delivered")]
[InlineData("--status", "DELIVERED")]
public void Query_FilterValueInAnyCase_IsAccepted(string option, string value)
{
// The defect row 99 names: these all failed client-side before the fix.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", option, value });
Assert.Empty(parse.Errors);
}
}