513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
1305 lines
60 KiB
C#
1305 lines
60 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 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";
|
|
|
|
// 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.
|
|
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
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that a query with valid parameters returns a JSON page of rows.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a query with a cursor returns the next page.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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>());
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a query without the operational-audit permission returns 403.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a query without credentials returns 401.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a query by a viewer is allowed.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Query_ViewerRole_IsAllowed()
|
|
{
|
|
// Viewer (the 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
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that a CSV export streams its content with a CSV content type.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that an export defaults to CSV when the format is omitted.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a JSON Lines export streams one record per line.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that the CSV header has 24 columns in the same order as the export row properties.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a formatted CSV row includes the execution id, parent execution id and source node.</summary>
|
|
[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
|
|
}
|
|
|
|
/// <summary>Verifies that a free-text field starting with a formula character is prefixed with a single quote in the CSV export.</summary>
|
|
/// <param name="malicious">The free-text value that starts with a formula character.</param>
|
|
/// <param name="expectedPrefix">The expected start of the exported cell.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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)
|
|
{
|
|
// CSV formula injection: 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the error message, response summary and extra columns are neutralized against formula injection.</summary>
|
|
[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
|
|
}
|
|
|
|
/// <summary>Verifies that negative durations and date columns are never neutralized.</summary>
|
|
[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
|
|
}
|
|
|
|
/// <summary>Verifies that a benign free-text field is not prefixed.</summary>
|
|
[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
|
|
}
|
|
|
|
/// <summary>Verifies that the CSV header columns match the property order of the export row.</summary>
|
|
[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(','));
|
|
}
|
|
|
|
/// <summary>Verifies that a Parquet export returns 501.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that an export without the audit-export permission returns 403.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that an export with an unsupported format returns 400.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that paging parsing clamps the page size to the maximum.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that repeated filter parameters parse into multi-value lists.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a single filter parameter parses into a one-element list.</summary>
|
|
[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.
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that an unrecognised enum value in a filter throws.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an unrecognised status throws an error naming the valid values.</summary>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that lowercase enum values are accepted in a filter.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a numeric enum value in a filter is rejected.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that an execution id filter parses into a single GUID value.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an unparseable execution id filter is silently dropped.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a parent execution id filter parses into a single GUID value.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an unparseable parent execution id filter is silently dropped.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that repeated channel parameters reach the repository as a multi-value filter.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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>());
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a half-supplied paging cursor is dropped.</summary>
|
|
[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
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that a system-wide user's filter is returned unchanged by site scoping.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an empty caller filter is restricted to the permitted sites for a scoped user.</summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that an explicit in-scope filter is kept unchanged for a scoped user.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an explicit out-of-scope filter yields null for a scoped user.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a mixed filter is reduced to its in-scope sites for a scoped user.</summary>
|
|
[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, string[]? permittedSites = 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>(), Arg.Any<IReadOnlyList<string>?>())
|
|
.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, permittedSites ?? Array.Empty<string>(), IsSystemWideDeployment: permittedSites is null));
|
|
|
|
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));
|
|
|
|
/// <summary>Verifies that a scoped viewer's permitted sites are passed to the repository by the tree endpoint.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Tree_ScopedViewer_ThreadsPermittedSitesToRepository()
|
|
{
|
|
var execution = Guid.NewGuid();
|
|
var (client, repo, host) = await BuildHostWithTreeAsync(new[] { "Viewer" }, permittedSites: new[] { "42" });
|
|
using (host)
|
|
{
|
|
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={execution:D}"));
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
await repo.Received().GetExecutionTreeAsync(execution, Arg.Any<CancellationToken>(),
|
|
Arg.Is<IReadOnlyList<string>>(sites => sites.SequenceEqual(new[] { "42" })));
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the tree endpoint returns a JSON array for a valid execution id.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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>());
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the tree endpoint returns an empty array when the repository returns nothing.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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());
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the tree endpoint returns 400 when the execution id is missing.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the tree endpoint returns 400 when the execution id is invalid.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the tree endpoint returns 403 without the operational-audit permission.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the tree endpoint returns 401 without credentials.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the tree endpoint is allowed for a viewer.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>Verifies that an administrator's source-node backfill returns 200 with the updated row count.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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());
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a viewer's source-node backfill returns 403.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a source-node backfill without credentials returns 401.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a source-node backfill without a before date returns 400.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a source-node backfill with an invalid before date returns 400.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a custom sentinel and batch size are passed to the repository.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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>());
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the sentinel defaults to unknown when it is omitted.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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>());
|
|
}
|
|
}
|
|
}
|