Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs
T
Joseph Doherty 9a3f006721 fix(management)+fix(cli): neutralize CSV formula injection + align export columns; deadline + Ctrl-C on audit export streaming (arch-review 2026-08-19 P6-7 + P6-8)
P6-7 (CWE-1236): AuditEndpoints CSV export quoted cells on comma/quote/CR/LF
but never neutralized a leading =, +, -, @, TAB, or CR — inbound-API-caller
text landing in Target/ErrorMessage/RequestSummary/ResponseSummary/Extra
could execute as a formula/DDE trigger when opened in Excel. AppendField now
prefixes a literal single quote on free-text columns only (never the
GUID/enum/numeric/date/bool columns the exporter formats itself), applied
before RFC 4180 quoting. The CSV was also missing ExecutionId/
ParentExecutionId/SourceNode that the JSONL export already carried; both are
now 24 columns in the same AuditExportRow order. A second, independently
maintained CSV writer (CentralUI's AuditLogExportService, feeding the Audit
Log page's bulk-export button) had the identical gap and got the identical
fix + column alignment.

P6-8: `audit export` was the one CLI HTTP path with no deadline at all — a
residual gap from the ManagementHttpClient timeout-honesty fix, which made
HttpClient.Timeout infinite and the per-call token the single deadline
everywhere except this streamed GET. RunExportAsync now takes a headers-
received deadline (CancelAfter, reusing ManagementHttpClient.
DefaultConnectTimeout) around the initial GetAsync, an inactivity deadline
around the streaming copy (rearmed on every successful read, so large exports
are bounded by silence, not total size), and an external CancellationToken
wired to Console.CancelKeyPress in AuditCommands so Ctrl-C cancels promptly.
A stalled/faulted stream now produces a friendly error noting the partial
output file instead of hanging or dumping a stack trace.
2026-08-19 15:01:45 -04:00

1211 lines
52 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// HTTP-pipeline tests for the #23 M8 audit endpoints (<see cref="AuditEndpoints"/>).
///
/// <para>
/// ManagementService authenticates each request by hand (HTTP Basic → LDAP →
/// roles) rather than via the ASP.NET authorization-policy pipeline, so these
/// tests substitute <see cref="LdapAuthService"/> + <see cref="RoleMapper"/>
/// (both expose a virtual test seam) to drive the role outcome without standing
/// up a directory. The repository is a stubbed <see cref="IAuditLogRepository"/>.
/// </para>
/// </summary>
public class AuditEndpointsTests
{
private const string BasicCredential = "auditor:password";
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory;
// the endpoint serializes the canonical record (eventId + occurredAtUtc top-level,
// domain fields out of DetailsJson) so these tests assert on the JSON/CSV output.
private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) =>
ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: id ?? Guid.Parse("11111111-1111-1111-1111-111111111111"),
occurredAtUtc: occurredAt ?? new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
sourceSiteId: "plant-a",
httpStatus: 200);
/// <summary>
/// Builds an in-process TestServer hosting the audit endpoints with stubbed
/// auth + repository. <paramref name="roles"/> is the role set the
/// substituted <see cref="RoleMapper"/> returns for the authenticated user;
/// pass an empty array to simulate a user with no audit permission.
/// </summary>
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync(
string[] roles,
IReadOnlyList<AuditEvent>[]? queryPages = null,
bool ldapSucceeds = true)
{
var repo = Substitute.For<IAuditLogRepository>();
if (queryPages is { Length: > 0 })
{
var returns = queryPages
.Select(p => Task.FromResult<IReadOnlyList<AuditEvent>>(p))
.ToArray();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(returns[0], returns.Skip(1).ToArray());
}
else
{
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
}
// Substituted LDAP bind — the shared ILdapAuthService is the seam now (Task 1.2).
var ldap = Substitute.For<ILdapAuthService>();
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ldapSucceeds
? LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" })
: LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
// Substituted role mapper — MapGroupsToRolesAsync is virtual (test seam).
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));
var hostBuilder = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(repo);
services.AddSingleton(ldap);
services.AddSingleton(roleMapper);
// ManagementAuthenticator now consults the login throttle before the bind.
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton<LoginThrottle>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
});
});
var host = await hostBuilder.StartAsync();
return (host.GetTestClient(), repo, host);
}
private static HttpRequestMessage Get(string url, string credential = BasicCredential)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
if (credential.Length > 0)
{
request.Headers.Authorization = new AuthenticationHeaderValue(
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential)));
}
return request;
}
// ─────────────────────────────────────────────────────────────────────
// /api/audit/query
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Query_ValidParams_ReturnsJsonPage()
{
var (client, _, host) = await BuildHostAsync(
roles: new[] { "Administrator" },
queryPages: new[] { (IReadOnlyList<AuditEvent>)new[] { SampleEvent() } });
using (host)
{
var response = await client.SendAsync(Get(
"/api/audit/query?channel=ApiOutbound&status=Delivered&pageSize=100"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var events = doc.RootElement.GetProperty("events");
Assert.Equal(1, events.GetArrayLength());
Assert.Equal("11111111-1111-1111-1111-111111111111",
events[0].GetProperty("eventId").GetString());
// A short page (1 row < pageSize 100) means no further pages.
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("nextCursor").ValueKind);
}
}
[Fact]
public async Task Query_WithCursor_ReturnsNextPage()
{
// First page is FULL (pageSize=2 → 2 rows) so the response carries a
// non-null nextCursor; the test then replays that cursor as the next
// request and asserts the repo saw a keyset-paged AuditLogPaging.
var pageOne = (IReadOnlyList<AuditEvent>)new[]
{
SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"),
new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)),
SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"),
new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)),
};
var (client, repo, host) = await BuildHostAsync(
roles: new[] { "Administrator" },
queryPages: new[] { pageOne });
using (host)
{
var first = await client.SendAsync(Get("/api/audit/query?pageSize=2"));
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
using var firstDoc = JsonDocument.Parse(await first.Content.ReadAsStringAsync());
var cursor = firstDoc.RootElement.GetProperty("nextCursor");
Assert.Equal(JsonValueKind.Object, cursor.ValueKind);
var afterEventId = cursor.GetProperty("afterEventId").GetString()!;
var afterOccurredAt = cursor.GetProperty("afterOccurredAtUtc").GetString()!;
Assert.Equal("aaaaaaaa-0000-0000-0000-000000000002", afterEventId);
// Replay the cursor — the endpoint must thread it into AuditLogPaging.
var second = await client.SendAsync(Get(
$"/api/audit/query?pageSize=2&afterEventId={afterEventId}&afterOccurredAtUtc={Uri.EscapeDataString(afterOccurredAt)}"));
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
await repo.Received().QueryAsync(
Arg.Any<AuditLogQueryFilter>(),
Arg.Is<AuditLogPaging>(p =>
p.PageSize == 2 &&
p.AfterEventId == Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002") &&
p.AfterOccurredAtUtc != null),
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task Query_WithoutOperationalAudit_Returns403()
{
// A user whose only role is Designer holds neither OperationalAudit nor
// AuditExport — the query endpoint must 403.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Designer" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/query"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
[Fact]
public async Task Query_WithoutCredentials_Returns401()
{
var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/query", credential: ""));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
[Fact]
public async Task Query_ViewerRole_IsAllowed()
{
// Viewer (post Task 1.7 home of the former AuditReadOnly role) satisfies
// OperationalAudit (read) — query must succeed.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/query"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
// ─────────────────────────────────────────────────────────────────────
// /api/audit/export
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Export_Csv_StreamsContent_WithCsvContentType()
{
var (client, _, host) = await BuildHostAsync(
roles: new[] { "Administrator" },
queryPages: new[]
{
(IReadOnlyList<AuditEvent>)new[] { SampleEvent() },
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
});
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
var disposition = response.Content.Headers.ContentDisposition;
Assert.NotNull(disposition);
Assert.Equal("attachment", disposition!.DispositionType);
Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase);
var body = await response.Content.ReadAsStringAsync();
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body);
Assert.Contains("11111111-1111-1111-1111-111111111111", body);
}
}
[Fact]
public async Task Export_Csv_DefaultsWhenFormatOmitted()
{
// No format= param → csv default.
var (client, _, host) = await BuildHostAsync(
roles: new[] { "Administrator" },
queryPages: new[] { (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>() });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
}
}
[Fact]
public async Task Export_Jsonl_StreamsOnePerLine()
{
var (client, _, host) = await BuildHostAsync(
roles: new[] { "Administrator" },
queryPages: new[]
{
(IReadOnlyList<AuditEvent>)new[]
{
SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001")),
SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")),
},
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
});
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=jsonl"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/x-ndjson", response.Content.Headers.ContentType!.MediaType);
var body = await response.Content.ReadAsStringAsync();
var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
// Each line must be a standalone, parseable JSON object.
foreach (var line in lines)
{
using var doc = JsonDocument.Parse(line);
Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind);
Assert.True(doc.RootElement.TryGetProperty("eventId", out _));
}
}
}
// ─────────────────────────────────────────────────────────────────────
// CSV export — formula-injection neutralization + column alignment
// (arch-review 2026-08-19 P6-7)
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void CsvHeader_Has24Columns_SameOrderAsAuditExportRowProperties()
{
// The JSONL export serializes AuditExportRow directly, so its field
// order IS the AuditExportRow property-declaration order; the CSV
// header must match it exactly (finding: the CSV was missing
// ExecutionId/ParentExecutionId/SourceNode that JSONL already carried).
var columns = AuditEndpoints.CsvHeader.Split(',');
Assert.Equal(24, columns.Length);
Assert.Equal(new[]
{
"EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind",
"CorrelationId", "ExecutionId", "ParentExecutionId",
"SourceSiteId", "SourceNode", "SourceInstanceId", "SourceScript",
"Actor", "Target", "Status", "HttpStatus", "DurationMs",
"ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary",
"PayloadTruncated", "Extra", "ForwardState",
}, columns);
}
[Fact]
public void FormatCsvRow_IncludesExecutionParentExecutionAndSourceNode()
{
var evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: Guid.Parse("11111111-1111-1111-1111-111111111111"),
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
sourceNode: "site-a-node-a",
executionId: Guid.Parse("22222222-2222-2222-2222-222222222222"),
parentExecutionId: Guid.Parse("33333333-3333-3333-3333-333333333333"));
var row = AuditEndpoints.FormatCsvRow(AuditExportRow.From(evt));
var fields = row.Split(',');
Assert.Equal(24, fields.Length);
Assert.Equal("22222222-2222-2222-2222-222222222222", fields[6]); // ExecutionId
Assert.Equal("33333333-3333-3333-3333-333333333333", fields[7]); // ParentExecutionId
Assert.Equal("site-a-node-a", fields[9]); // SourceNode
}
[Theory]
[InlineData("=HYPERLINK(\"http://evil\",\"click\")", "'=HYPERLINK(")]
[InlineData("+cmd|'/c calc'!A1", "'+cmd|")]
[InlineData("-2+3", "'-2+3")]
[InlineData("@SUM(1,1)", "'@SUM(1,1)")]
[InlineData("\tsneaky", "'\tsneaky")]
public async Task Export_Csv_FreeTextLeadingFormulaCharacter_IsPrefixedWithQuote(string malicious, string expectedPrefix)
{
// CWE-1236: Target is inbound-API-caller-controlled free text. A raw
// leading =, +, -, @, or TAB must never reach the CSV cell unescaped.
var evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiInbound,
kind: AuditKind.InboundRequest,
status: AuditStatus.Delivered,
eventId: Guid.Parse("44444444-4444-4444-4444-444444444444"),
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
target: malicious);
var (client, _, host) = await BuildHostAsync(
roles: new[] { "Administrator" },
queryPages: new[]
{
(IReadOnlyList<AuditEvent>)new[] { evt },
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
});
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
var body = await response.Content.ReadAsStringAsync();
Assert.Contains(expectedPrefix, body);
// The raw, un-neutralized value must never appear on its own —
// every occurrence must be the neutralized ('-prefixed) form.
Assert.DoesNotContain("," + malicious + ",", body);
}
}
[Fact]
public void FormatCsvRow_ErrorMessageResponseSummaryAndExtra_AreNeutralized()
{
// Covers the finding's named secondary carriers beyond Target.
var evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.DbOutbound,
kind: AuditKind.DbWrite,
status: AuditStatus.Failed,
eventId: Guid.Parse("55555555-5555-5555-5555-555555555555"),
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
errorMessage: "=cmd()",
responseSummary: "+1+1",
extra: "@evil");
var row = AuditEndpoints.FormatCsvRow(AuditExportRow.From(evt));
var fields = row.Split(',');
Assert.Equal("'=cmd()", fields[17]); // ErrorMessage
Assert.Equal("'+1+1", fields[20]); // ResponseSummary
Assert.Equal("'@evil", fields[22]); // Extra
}
[Fact]
public void FormatCsvRow_NegativeDurationAndDateColumns_NeverNeutralized()
{
// DurationMs/OccurredAtUtc are formatted by the exporter itself, never
// free text — a negative duration's leading '-' must render as-is, not
// be mistaken for a formula trigger and quote-prefixed.
var evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: Guid.Parse("66666666-6666-6666-6666-666666666666"),
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
httpStatus: 200,
durationMs: -7);
var row = AuditEndpoints.FormatCsvRow(AuditExportRow.From(evt));
var fields = row.Split(',');
Assert.Equal("-7", fields[16]); // DurationMs, unprefixed
Assert.StartsWith("2026-05-20", fields[1]); // OccurredAtUtc, unprefixed
Assert.Equal("200", fields[15]); // HttpStatus, unprefixed
}
[Fact]
public void FormatCsvRow_BenignFreeTextField_IsNotPrefixed()
{
var evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
eventId: Guid.Parse("77777777-7777-7777-7777-777777777777"),
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
target: "plant-a-external-system");
var row = AuditEndpoints.FormatCsvRow(AuditExportRow.From(evt));
var fields = row.Split(',');
Assert.Equal("plant-a-external-system", fields[13]); // Target
}
[Fact]
public void CsvHeader_ColumnsMatchAuditExportRowPropertyOrder()
{
// The JSONL export serializes AuditExportRow verbatim (property
// declaration order = JSON field order once camelCased); assert the
// CSV header names + order are identical to that property surface, so
// a column can never again silently drift between the two formats.
// (Reflection, not a live JSONL round-trip: JsonOptions drops null
// properties on write — e.g. ForwardState is always null on the
// central read path — which would otherwise hide a real column from
// a JSON-based comparison.)
var propertyNames = typeof(AuditExportRow)
.GetProperties()
.Select(p => p.Name)
.ToArray();
Assert.Equal(propertyNames, AuditEndpoints.CsvHeader.Split(','));
}
[Fact]
public async Task Export_Parquet_Returns501()
{
// Parquet archival is deferred to v1.x (Component-AuditLog.md) — no
// library is referenced, so the endpoint returns 501 with guidance.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=parquet"));
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("Parquet export deferred to v1.x", body);
}
}
[Fact]
public async Task Export_WithoutAuditExport_Returns403()
{
// Viewer (former AuditReadOnly) grants read (OperationalAudit) but NOT
// bulk export (AuditExport) — the export endpoint must 403. This is the
// preserved half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
[Fact]
public async Task Export_UnsupportedFormat_Returns400()
{
var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=xml"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
// ─────────────────────────────────────────────────────────────────────
// Query-string parsing units
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void ParsePaging_ClampsPageSizeToMax()
{
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["pageSize"] = "999999",
});
var paging = AuditEndpoints.ParsePaging(query);
Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize);
}
[Fact]
public void ParseFilter_RepeatedParams_ParseIntoMultiValueLists()
{
// Repeated query params (channel=A&channel=B …) must widen to multi-value
// filter lists — one element per supplied value.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["channel"] = new[] { "ApiOutbound", "DbOutbound" },
["kind"] = new[] { "ApiCall", "DbWrite" },
["status"] = new[] { "Failed", "Parked" },
["sourceSiteId"] = new[] { "plant-a", "plant-b" },
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, filter.Channels);
Assert.Equal(new[] { AuditKind.ApiCall, AuditKind.DbWrite }, filter.Kinds);
Assert.Equal(new[] { AuditStatus.Failed, AuditStatus.Parked }, filter.Statuses);
Assert.Equal(new[] { "plant-a", "plant-b" }, filter.SourceSiteIds);
}
[Fact]
public void ParseFilter_SingleParam_ParsesIntoOneElementList()
{
// The single-valued contract still holds — one param yields a
// one-element list, not a scalar.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["channel"] = "ApiOutbound",
["status"] = "Delivered",
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(new[] { AuditChannel.ApiOutbound }, filter.Channels);
Assert.Equal(new[] { AuditStatus.Delivered }, filter.Statuses);
Assert.Null(filter.Kinds);
Assert.Null(filter.SourceSiteIds);
}
// ========================================================================
// Register row 99: the enum filter dimensions parse STRICTLY here.
//
// These used to assert the LAX contract — an unrecognised enum name was
// dropped and the query ran unfiltered on that dimension, so `--status
// Nonsense` returned a plausible, complete-looking page that did not answer
// the question asked. That is exactly the failure row 88 fixed for `deploy
// status`, and it became reachable from the CLI once the (case-SENSITIVE,
// and therefore over-strict) client-side AcceptOnlyFromAmong guard was
// removed. The Guid/date dimensions stay lax — see ParseFilter's remarks.
// ========================================================================
[Fact]
public void ParseFilter_UnrecognisedEnumValue_Throws()
{
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["channel"] = new[] { "ApiOutbound", "Bogus", "Notification" },
});
var ex = Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query));
Assert.Contains("Bogus", ex.Message);
Assert.Contains("channel", ex.Message);
}
[Fact]
public void ParseFilter_UnrecognisedStatus_ThrowsNamingTheValidSet()
{
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["status"] = new[] { "Nonsense" },
});
var ex = Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query));
foreach (var name in Enum.GetNames<AuditStatus>())
{
Assert.Contains(name, ex.Message);
}
}
[Fact]
public void ParseFilter_LowercaseEnumValues_AreAccepted()
{
// The CLI sends whatever the operator typed now that its own case-sensitive
// guard is gone; `--status delivered` must resolve here.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["channel"] = new[] { "apioutbound" },
["kind"] = new[] { "apicall" },
["status"] = new[] { "delivered", "PARKED" },
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(new[] { AuditChannel.ApiOutbound }, filter.Channels);
Assert.Equal(new[] { AuditKind.ApiCall }, filter.Kinds);
Assert.Equal(new[] { AuditStatus.Delivered, AuditStatus.Parked }, filter.Statuses);
}
[Fact]
public void ParseFilter_NumericEnumValue_IsRejected()
{
// Enum.TryParse would accept "3" as an ordinal; a filter is a name-based
// contract, so it must not.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["status"] = new[] { "3" },
});
Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query));
}
[Fact]
public void ParseFilter_ExecutionId_ParsesIntoSingleValueGuid()
{
// executionId is a single-value Guid? filter — mirrors correlationId.
var executionId = Guid.NewGuid();
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["executionId"] = executionId.ToString(),
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(executionId, filter.ExecutionId);
}
[Fact]
public void ParseFilter_UnparseableExecutionId_IsDroppedSilently()
{
// Lax-parse contract: an unparseable executionId is dropped (no 400) —
// mirrors the correlationId parse.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["executionId"] = "not-a-guid",
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Null(filter.ExecutionId);
}
[Fact]
public void ParseFilter_ParentExecutionId_ParsesIntoSingleValueGuid()
{
// parentExecutionId is a single-value Guid? filter — mirrors executionId.
var parentExecutionId = Guid.NewGuid();
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["parentExecutionId"] = parentExecutionId.ToString(),
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(parentExecutionId, filter.ParentExecutionId);
}
[Fact]
public void ParseFilter_UnparseableParentExecutionId_IsDroppedSilently()
{
// Lax-parse contract: an unparseable parentExecutionId is dropped (no 400) —
// mirrors the executionId parse.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["parentExecutionId"] = "not-a-guid",
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Null(filter.ParentExecutionId);
}
[Fact]
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
{
// End-to-end: a repeated channel= query param must surface at the
// repository as a two-element Channels list.
var (client, repo, host) = await BuildHostAsync(roles: new[] { "Administrator" });
using (host)
{
var response = await client.SendAsync(Get(
"/api/audit/query?channel=ApiOutbound&channel=DbOutbound"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channels != null && f.Channels.Count == 2 &&
f.Channels.Contains(AuditChannel.ApiOutbound) &&
f.Channels.Contains(AuditChannel.DbOutbound)),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
[Fact]
public void ParsePaging_HalfSuppliedCursor_IsDropped()
{
// afterEventId without afterOccurredAtUtc is an invalid keyset cursor —
// both must be dropped so the repository gets a first-page request.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["afterEventId"] = Guid.NewGuid().ToString(),
});
var paging = AuditEndpoints.ParsePaging(query);
Assert.Null(paging.AfterEventId);
Assert.Null(paging.AfterOccurredAtUtc);
}
// ─────────────────────────────────────────────────────────────────────
// ApplySiteScope (Management-019)
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged()
{
// Empty PermittedSiteIds is the system-wide signal (Administrator,
// system-wide Deployer, audit roles with no scope rules attached). The
// filter should pass through with no restriction added.
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "Administrator" }, Array.Empty<string>());
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Same(filter, result);
}
[Fact]
public void ApplySiteScope_ScopedUser_EmptyCallerFilter_RestrictedToPermittedSet()
{
// No explicit sourceSiteId from the caller — the helper must restrict
// the query to the user's permitted set, otherwise a site-scoped audit
// user could read every site's rows.
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "Viewer" }, new[] { "plant-a", "plant-b" });
var filter = new AuditLogQueryFilter();
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.NotNull(result!.SourceSiteIds);
Assert.Equal(new[] { "plant-a", "plant-b" }, result.SourceSiteIds!.OrderBy(s => s).ToArray());
}
[Fact]
public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim()
{
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "Viewer" }, new[] { "plant-a", "plant-b" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
}
[Fact]
public void ApplySiteScope_ScopedUser_ExplicitOutOfScopeFilter_ReturnsNull()
{
// Caller explicitly asked for a site they cannot see — the helper signals
// "403" by returning null rather than silently producing an empty page.
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "Viewer" }, new[] { "plant-a" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-b" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.Null(result);
}
[Fact]
public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly()
{
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "Viewer" }, new[] { "plant-a" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a", "plant-b" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
}
// ─────────────────────────────────────────────────────────────────────
// /api/audit/tree
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Builds a TestServer with the audit-log endpoints wired up and the repository
/// stub returning the supplied <paramref name="treeNodes"/> for
/// <c>GetExecutionTreeAsync</c>.
/// </summary>
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithTreeAsync(
string[] roles,
IReadOnlyList<ExecutionTreeNode>? treeNodes = null)
{
var repo = Substitute.For<IAuditLogRepository>();
// Default QueryAsync stub so the shared host initialisation does not fail.
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var returnNodes = treeNodes ?? Array.Empty<ExecutionTreeNode>();
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(returnNodes));
var ldap = Substitute.For<ILdapAuthService>();
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" }));
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));
var hostBuilder = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(repo);
services.AddSingleton(ldap);
services.AddSingleton(roleMapper);
// ManagementAuthenticator now consults the login throttle before the bind.
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton<LoginThrottle>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
});
});
var host = await hostBuilder.StartAsync();
return (host.GetTestClient(), repo, host);
}
private static ExecutionTreeNode MakeNode(Guid id, Guid? parentId = null, int rowCount = 2) =>
new ExecutionTreeNode(
ExecutionId: id,
ParentExecutionId: parentId,
RowCount: rowCount,
Channels: new[] { "ApiOutbound" },
Statuses: new[] { "Delivered" },
SourceSiteId: "plant-a",
SourceInstanceId: "inst-1",
FirstOccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
LastOccurredAtUtc: new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc));
[Fact]
public async Task Tree_ValidExecutionId_ReturnsJsonArray()
{
var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001");
var child = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002");
var nodes = new[]
{
MakeNode(root),
MakeNode(child, parentId: root),
};
var (client, repo, host) = await BuildHostWithTreeAsync(
roles: new[] { "Administrator" },
treeNodes: nodes);
using (host)
{
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={root:D}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(2, doc.RootElement.GetArrayLength());
await repo.Received(1).GetExecutionTreeAsync(root, Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task Tree_RepoReturnsEmpty_ReturnsEmptyArray()
{
var id = Guid.NewGuid();
var (client, _, host) = await BuildHostWithTreeAsync(
roles: new[] { "Administrator" },
treeNodes: Array.Empty<ExecutionTreeNode>());
using (host)
{
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={id:D}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
}
[Fact]
public async Task Tree_MissingExecutionId_Returns400()
{
var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/tree"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
[Fact]
public async Task Tree_InvalidExecutionId_Returns400()
{
var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/tree?executionId=not-a-guid"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("BAD_REQUEST", body);
}
}
[Fact]
public async Task Tree_WithoutOperationalAudit_Returns403()
{
var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Designer" });
using (host)
{
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
[Fact]
public async Task Tree_WithoutCredentials_Returns401()
{
var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" });
using (host)
{
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}", credential: ""));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
[Fact]
public async Task Tree_ViewerRole_IsAllowed()
{
var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Viewer" });
using (host)
{
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
// ─────────────────────────────────────────────────────────────────────
// POST /api/audit/backfill-source-node (M5.6 T5)
// ─────────────────────────────────────────────────────────────────────
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithBackfillAsync(
string[] roles,
long backfillResult = 42L,
bool ldapSucceeds = true)
{
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
repo.BackfillSourceNodeAsync(
Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(backfillResult));
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.ExecutionTreeNode>>(
Array.Empty<ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.ExecutionTreeNode>()));
var ldap = Substitute.For<ILdapAuthService>();
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ldapSucceeds
? LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" })
: LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));
var hostBuilder = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(repo);
services.AddSingleton(ldap);
services.AddSingleton(roleMapper);
// ManagementAuthenticator now consults the login throttle before the bind.
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton<LoginThrottle>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
});
});
var host = await hostBuilder.StartAsync();
return (host.GetTestClient(), repo, host);
}
private static HttpRequestMessage Post(string url, string body, string credential = BasicCredential)
{
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
if (credential.Length > 0)
{
request.Headers.Authorization = new AuthenticationHeaderValue(
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential)));
}
return request;
}
[Fact]
public async Task BackfillSourceNode_AdminRole_Returns200WithRowCount()
{
var (client, _, host) = await BuildHostWithBackfillAsync(
roles: new[] { "Administrator" }, backfillResult: 12345L);
using (host)
{
var response = await client.SendAsync(Post(
"/api/audit/backfill-source-node",
"{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = doc.RootElement;
Assert.Equal(12345L, root.GetProperty("rowsUpdated").GetInt64());
Assert.Equal("unknown", root.GetProperty("sentinel").GetString());
}
}
[Fact]
public async Task BackfillSourceNode_ViewerRole_Returns403()
{
// Viewer has OperationalAudit but NOT the Admin-only backfill permission.
var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Viewer" });
using (host)
{
var response = await client.SendAsync(Post(
"/api/audit/backfill-source-node",
"{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
[Fact]
public async Task BackfillSourceNode_NoCredentials_Returns401()
{
var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" });
using (host)
{
var response = await client.SendAsync(Post(
"/api/audit/backfill-source-node",
"{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}",
credential: ""));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
[Fact]
public async Task BackfillSourceNode_MissingBefore_Returns400()
{
var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" });
using (host)
{
// No "before" field — required.
var response = await client.SendAsync(Post(
"/api/audit/backfill-source-node",
"{\"sentinel\":\"unknown\"}"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
[Fact]
public async Task BackfillSourceNode_InvalidBeforeDate_Returns400()
{
var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" });
using (host)
{
var response = await client.SendAsync(Post(
"/api/audit/backfill-source-node",
"{\"sentinel\":\"unknown\",\"before\":\"not-a-date\"}"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
[Fact]
public async Task BackfillSourceNode_CustomSentinelAndBatch_PassedToRepo()
{
var (client, repo, host) = await BuildHostWithBackfillAsync(
roles: new[] { "Administrator" }, backfillResult: 7L);
using (host)
{
var response = await client.SendAsync(Post(
"/api/audit/backfill-source-node",
"{\"sentinel\":\"pre-feature\",\"before\":\"2026-01-01T00:00:00Z\",\"batchSize\":2000}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await repo.Received(1).BackfillSourceNodeAsync(
"pre-feature",
Arg.Is<DateTime>(d => d.Year == 2026 && d.Month == 1 && d.Day == 1),
2000,
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task BackfillSourceNode_DefaultSentinel_IsUnknown_WhenOmitted()
{
var (client, repo, host) = await BuildHostWithBackfillAsync(
roles: new[] { "Administrator" }, backfillResult: 0L);
using (host)
{
// Omit "sentinel" — endpoint defaults to "unknown".
var response = await client.SendAsync(Post(
"/api/audit/backfill-source-node",
"{\"before\":\"2026-01-01T00:00:00Z\"}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await repo.Received(1).BackfillSourceNodeAsync(
"unknown",
Arg.Any<DateTime>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>());
}
}
}