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