feat(audit): M5.6 SourceNode sentinel backfill (purge-role) + CLI + runbook note (T5)

This commit is contained in:
Joseph Doherty
2026-06-16 22:02:21 -04:00
parent de2968b03d
commit 55630b48b6
12 changed files with 1399 additions and 10 deletions
@@ -0,0 +1,244 @@
using System.CommandLine;
using System.Net;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Tests for the <c>scadabridge audit backfill-source-node</c> subcommand
/// (Audit Log #23 M5.6 T5): argument parsing, request-body construction,
/// HTTP wiring, and CLI scaffold.
/// </summary>
[Collection("Console")]
public class AuditBackfillCommandTests
{
// ─────────────────────────────────────────────────────────────────────
// BuildRequestBody
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void BuildRequestBody_DefaultArgs_ContainsExpectedFields()
{
var args = new AuditBackfillSourceNodeArgs
{
Sentinel = "unknown",
Before = "2026-01-01T00:00:00Z",
BatchSize = 5000,
};
var body = AuditBackfillHelpers.BuildRequestBody(args);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.Equal("unknown", root.GetProperty("sentinel").GetString());
Assert.Equal("2026-01-01T00:00:00Z", root.GetProperty("before").GetString());
Assert.Equal(5000, root.GetProperty("batchSize").GetInt32());
}
[Fact]
public void BuildRequestBody_CustomSentinelAndBatch_ReflectedInJson()
{
var args = new AuditBackfillSourceNodeArgs
{
Sentinel = "pre-feature",
Before = "2026-06-01T00:00:00Z",
BatchSize = 1000,
};
var body = AuditBackfillHelpers.BuildRequestBody(args);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.Equal("pre-feature", root.GetProperty("sentinel").GetString());
Assert.Equal("2026-06-01T00:00:00Z", root.GetProperty("before").GetString());
Assert.Equal(1000, root.GetProperty("batchSize").GetInt32());
}
// ─────────────────────────────────────────────────────────────────────
// RunBackfillAsync — HTTP execution
// ─────────────────────────────────────────────────────────────────────
private sealed class CapturingHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly string _responseBody;
public CapturingHandler(HttpStatusCode status, string responseBody)
{
_status = status;
_responseBody = responseBody;
}
public string? LastRequestUri { get; private set; }
public string? LastRequestBody { get; private set; }
public string? LastMethod { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequestUri = request.RequestUri!.PathAndQuery;
LastMethod = request.Method.Method;
if (request.Content != null)
{
LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken);
}
return new HttpResponseMessage(_status)
{
Content = new StringContent(_responseBody, Encoding.UTF8, "application/json"),
};
}
}
private static string SuccessBody(long rowsUpdated = 42, string sentinel = "unknown", string before = "2026-01-01T00:00:00.0000000Z")
=> JsonSerializer.Serialize(new { rowsUpdated, sentinel, before });
[Fact]
public async Task RunBackfill_Success_ReturnsZeroAndWritesOutput()
{
var handler = new CapturingHandler(HttpStatusCode.OK, SuccessBody(rowsUpdated: 42));
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var args = new AuditBackfillSourceNodeArgs
{
Sentinel = "unknown",
Before = "2026-01-01T00:00:00Z",
BatchSize = 5000,
};
var exit = await AuditBackfillHelpers.RunBackfillAsync(client, args, output);
Assert.Equal(0, exit);
var text = output.ToString();
Assert.Contains("42", text);
Assert.Contains("backfill complete", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RunBackfill_RequestUri_ContainsBackfillPath()
{
var handler = new CapturingHandler(HttpStatusCode.OK, SuccessBody());
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
await AuditBackfillHelpers.RunBackfillAsync(
client,
new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z" },
output);
Assert.Contains("backfill-source-node", handler.LastRequestUri);
Assert.Equal("POST", handler.LastMethod);
}
[Fact]
public async Task RunBackfill_RequestBody_ContainsSentinelAndBefore()
{
var handler = new CapturingHandler(HttpStatusCode.OK, SuccessBody());
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
await AuditBackfillHelpers.RunBackfillAsync(
client,
new AuditBackfillSourceNodeArgs
{
Sentinel = "pre-feature",
Before = "2026-01-01T00:00:00Z",
BatchSize = 2000,
},
output);
Assert.NotNull(handler.LastRequestBody);
using var doc = JsonDocument.Parse(handler.LastRequestBody!);
Assert.Equal("pre-feature", doc.RootElement.GetProperty("sentinel").GetString());
Assert.Equal("2026-01-01T00:00:00Z", doc.RootElement.GetProperty("before").GetString());
Assert.Equal(2000, doc.RootElement.GetProperty("batchSize").GetInt32());
}
[Fact]
public async Task RunBackfill_Http403_ReturnsExitCode2()
{
var handler = new CapturingHandler(HttpStatusCode.Forbidden,
"{\"error\":\"Permission required.\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditBackfillHelpers.RunBackfillAsync(
client,
new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z" },
output);
Assert.Equal(2, exit);
}
[Fact]
public async Task RunBackfill_Http500_ReturnsExitCode1()
{
var handler = new CapturingHandler(HttpStatusCode.InternalServerError,
"{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditBackfillHelpers.RunBackfillAsync(
client,
new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z" },
output);
Assert.Equal(1, exit);
}
// ─────────────────────────────────────────────────────────────────────
// CLI parsing
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void BackfillSourceNode_Subcommand_ExistsInAuditCommandGroup()
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "backfill-source-node", "--help" });
Assert.Empty(parse.Errors);
}
[Fact]
public void BackfillSourceNode_BeforeOption_IsRequired()
{
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "backfill-source-node");
Assert.NotEqual(0, exit);
}
[Fact]
public void BackfillSourceNode_HelpText_DescribesSentinelAndBefore()
{
var root = AuditCommandTestHarness.BuildRoot();
var output = new StringWriter();
var exit = root.Parse(new[] { "audit", "backfill-source-node", "--help" })
.Invoke(new InvocationConfiguration { Output = output });
Assert.Equal(0, exit);
var text = output.ToString();
Assert.Contains("sentinel", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("before", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void BackfillSourceNode_DefaultSentinel_IsUnknown()
{
// Verify the default sentinel value is "unknown" as documented.
var url = new Option<string>("--url") { Recursive = true };
var username = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
var format = CliOptions.CreateFormatOption();
var auditGroup = AuditCommands.Build(url, format, username, password);
var backfillCmd = auditGroup.Subcommands
.FirstOrDefault(c => c.Name == "backfill-source-node");
Assert.NotNull(backfillCmd);
// The subcommand exists and its description mentions maintenance/sentinel.
Assert.False(string.IsNullOrWhiteSpace(backfillCmd!.Description));
}
}
@@ -5,8 +5,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Scaffold tests for the <c>scadabridge audit</c> command group (Audit Log #23 M8-T1).
/// Verifies the parent command exists with its three subcommands and that every leaf
/// has an action wired.
/// Verifies the parent command exists with its subcommands and that every leaf
/// has an action wired. Updated for M5.6 T5 to cover <c>backfill-source-node</c>.
/// </summary>
public class AuditCommandsScaffoldTests
{
@@ -27,11 +27,13 @@ public class AuditCommandsScaffoldTests
}
[Fact]
public void Audit_HasThreeSubcommands_QueryExportVerifyChain()
public void Audit_HasFiveSubcommands_QueryExportTreeVerifyChainBackfillSourceNode()
{
var audit = BuildAudit();
var names = audit.Subcommands.Select(c => c.Name).OrderBy(n => n).ToArray();
Assert.Equal(new[] { "export", "query", "verify-chain" }, names);
Assert.Equal(
new[] { "backfill-source-node", "export", "query", "tree", "verify-chain" },
names);
}
[Fact]
@@ -48,7 +50,9 @@ public class AuditCommandsScaffoldTests
var text = output.ToString();
Assert.Contains("query", text);
Assert.Contains("export", text);
Assert.Contains("tree", text);
Assert.Contains("verify-chain", text);
Assert.Contains("backfill-source-node", text);
}
[Fact]
@@ -31,9 +31,40 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// targeting the AuditLog entity are NOT covered and must never be introduced.
/// Additionally, the scan is line-oriented: DML where the keyword and table name appear
/// on separate lines is an accepted, undetected edge case.
///
/// <b>Allow-list.</b> Two narrow maintenance-path exemptions carry the exact
/// <see cref="AuditPurgeAllowedMarker"/> trailing comment:
/// <list type="bullet">
/// <item><description>
/// M5.5 (T3) — <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>: the
/// one sanctioned batched <c>DELETE TOP (@batch) FROM dbo.AuditLog</c>,
/// running on the purge/maintenance connection.
/// </description></item>
/// <item><description>
/// M5.6 (T5) — <c>AuditLogRepository.BackfillSourceNodeAsync</c>: the
/// one sanctioned batched <c>UPDATE TOP (@batch) dbo.AuditLog SET SourceNode</c>,
/// running on the maintenance connection. The sentinel backfill is a
/// one-time ops procedure; the append-only invariant still applies to all
/// other columns and all other UPDATE forms.
/// </description></item>
/// </list>
/// The allow-list is applied in the file-scan test only
/// (<see cref="ConfigurationDatabase_ShouldNotContainAuditLogMutations"/>) — the
/// raw mutation matcher (<see cref="ContainsAuditLogMutation"/>) is marker-blind,
/// so the matcher's self-tests remain honest and any OTHER UPDATE/DELETE against
/// AuditLog (or any DML lacking the marker) still fails the build.
/// </summary>
public class AuditLogAppendOnlyGuardTests
{
/// <summary>
/// The exact trailing-comment marker that exempts a single sanctioned
/// maintenance-path DML line from the append-only guard. Carried at the END of
/// the SQL constant string in both <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>
/// (M5.5 T3 batched DELETE) and <c>AuditLogRepository.BackfillSourceNodeAsync</c>
/// (M5.6 T5 batched UPDATE). Kept deliberately specific so it cannot be pasted
/// onto an unrelated mutation without a reviewer noticing.
/// </summary>
internal const string AuditPurgeAllowedMarker = "AUDIT-PURGE-ALLOWED";
// ---------------------------------------------------------------------------
// Source root location — same walk-up pattern used by ArchitecturalConstraintTests
// in the Commons.Tests project.
@@ -133,11 +164,38 @@ public class AuditLogAppendOnlyGuardTests
return AuditLogMutationPattern.IsMatch(text);
}
// The DELETE branch tolerates an optional TOP (...) batch-size clause between
// DELETE and the (optional) FROM — e.g. "DELETE TOP (@batch) FROM dbo.AuditLog"
// (the M5.5 T3 batched purge shape). Without this the guard would silently miss a
// batched row DELETE against AuditLog, which is exactly the kind of mutation it
// must catch. The TOP sub-pattern is (?:TOP\s*\(.*?\)\s+)? — optional, lazy inside
// the parens so it never swallows past the matching ')'.
//
// The UPDATE branch similarly tolerates an optional TOP (...) clause between
// UPDATE and (optional schema.) AuditLog — e.g.
// "UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel …"
// (the M5.6 T5 batched backfill shape).
private static readonly Regex AuditLogMutationPattern = new(
@"\bUPDATE\s+(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b" +
@"|\bDELETE\s+(?:FROM\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b",
@"\bUPDATE\s+(?:TOP\s*\(.*?\)\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b" +
@"|\bDELETE\s+(?:TOP\s*\(.*?\)\s+)?(?:FROM\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// Returns <see langword="true"/> when <paramref name="line"/> carries the narrow
/// <see cref="AuditPurgeAllowedMarker"/> exemption. Sanctioned uses are:
/// <list type="bullet">
/// <item><description>M5.5 T3 — the per-channel maintenance-path batched DELETE.</description></item>
/// <item><description>M5.6 T5 — the SourceNode sentinel batched UPDATE.</description></item>
/// </list>
/// A flagged line that lacks the marker is NOT allow-listed. The mutation matcher
/// itself stays marker-blind; the allow-list is applied only by the file-scan test,
/// so the matcher's self-tests still observe the raw mutation.
/// </summary>
/// <param name="line">A single source line already known to contain a mutation.</param>
/// <returns><see langword="true"/> if the line is a sanctioned maintenance-path exemption.</returns>
internal static bool IsAllowListed(string line) =>
line.Contains(AuditPurgeAllowedMarker, StringComparison.Ordinal);
// ---------------------------------------------------------------------------
// Guard test: scan every *.cs file in ConfigurationDatabase (excluding
// Designer/Snapshot EF artefacts and the obj/ directory).
@@ -168,7 +226,7 @@ public class AuditLogAppendOnlyGuardTests
var lines = content.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
if (ContainsAuditLogMutation(lines[i]))
if (ContainsAuditLogMutation(lines[i]) && !IsAllowListed(lines[i]))
{
var relativePath = Path.GetRelativePath(sourceDir, file);
violations.Add($"{relativePath}:{i + 1}: {lines[i].Trim()}");
@@ -179,7 +237,7 @@ public class AuditLogAppendOnlyGuardTests
Assert.True(violations.Count == 0,
"AuditLog append-only guard: found UPDATE/DELETE targeting dbo.AuditLog " +
"in ConfigurationDatabase source. AuditLog is APPEND-ONLY (retention uses " +
"partition-switch DDL, not row DELETE). Violation(s):\n" +
"partition-switch DDL, not row DELETE/UPDATE). Violation(s):\n" +
string.Join("\n", violations));
}
@@ -285,6 +343,27 @@ public class AuditLogAppendOnlyGuardTests
// DELETE FROM [AuditLog] — bracketed table, no schema prefix.
Assert.True(ContainsAuditLogMutation(
"DELETE FROM [AuditLog] WHERE OccurredAtUtc < @threshold;"));
// ---- Batched DELETE TOP (...) forms (M5.5 T3 purge shape) ----
// The matcher must catch a batched DELETE against AuditLog regardless of the
// marker — the allow-list (IsAllowListed) is what forgives the ONE sanctioned
// line, not the matcher.
Assert.True(ContainsAuditLogMutation(
"DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;"));
Assert.True(ContainsAuditLogMutation(
"DELETE TOP (5000) FROM dbo.AuditLog WHERE OccurredAtUtc < @threshold;"));
Assert.True(ContainsAuditLogMutation(
"DELETE TOP(100) FROM [dbo].[AuditLog] WHERE Status = 'Parked';"));
// ---- Batched UPDATE TOP (...) forms (M5.6 T5 backfill shape) ----
// The matcher must also catch a batched UPDATE against AuditLog, regardless of
// the marker — the allow-list is what forgives the ONE sanctioned backfill line.
Assert.True(ContainsAuditLogMutation(
"UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before;"));
Assert.True(ContainsAuditLogMutation(
"UPDATE TOP (500) dbo.AuditLog SET SourceNode = 'unknown' WHERE SourceNode IS NULL;"));
Assert.True(ContainsAuditLogMutation(
"UPDATE TOP(100) [dbo].[AuditLog] SET SourceNode = @s WHERE SourceNode IS NULL;"));
}
[Fact]
@@ -315,4 +394,75 @@ public class AuditLogAppendOnlyGuardTests
Assert.False(ContainsAuditLogMutation(
"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc < @cutoff;"));
}
// ---------------------------------------------------------------------------
// Allow-list self-tests (M5.5 T3 / M5.6 T5) — prove the narrow exemption only
// forgives the marked maintenance-path DML and still blocks everything else.
// ---------------------------------------------------------------------------
[Fact]
public void AllowList_ForgivesMarkedPurgeDelete_ButMatcherStillTrips()
{
// The sanctioned per-channel purge DELETE — verbatim shape from
// AuditLogRepository.PurgeChannelOlderThanAsync, carrying the trailing marker.
const string sanctioned =
"\"DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;\"; " +
"// AUDIT-PURGE-ALLOWED: per-channel retention override (M5.5 T3), maintenance path";
// The raw matcher STILL sees the mutation (the matcher is marker-blind) ...
Assert.True(ContainsAuditLogMutation(sanctioned));
// ... but the allow-list forgives it because of the trailing marker.
Assert.True(IsAllowListed(sanctioned));
}
[Fact]
public void AllowList_ForgivesMarkedBackfillUpdate_ButMatcherStillTrips()
{
// The sanctioned SourceNode sentinel backfill UPDATE — verbatim shape from
// AuditLogRepository.BackfillSourceNodeAsync, carrying the trailing marker.
const string sanctioned =
"\"UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before;\"; " +
"// AUDIT-PURGE-ALLOWED: SourceNode sentinel backfill (M5.6 T5), maintenance path";
// The raw matcher STILL sees the mutation (the matcher is marker-blind) ...
Assert.True(ContainsAuditLogMutation(sanctioned));
// ... but the allow-list forgives it because of the trailing marker.
Assert.True(IsAllowListed(sanctioned));
}
[Fact]
public void AllowList_DoesNotForgive_UnmarkedStrayDelete()
{
// A stray DELETE against AuditLog WITHOUT the marker — exactly the kind of
// regression the guard exists to catch. It must be flagged (matcher) AND not
// forgiven (allow-list), so the file-scan test would record it as a violation.
const string stray = "DELETE FROM dbo.AuditLog WHERE Status = 'Parked';";
Assert.True(ContainsAuditLogMutation(stray));
Assert.False(IsAllowListed(stray),
"A DELETE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed.");
}
[Fact]
public void AllowList_DoesNotForgive_UnmarkedStrayUpdate()
{
// A stray UPDATE against AuditLog WITHOUT the marker — must still trip the guard.
const string stray = "UPDATE dbo.AuditLog SET Status = 'Corrected' WHERE EventId = @id;";
Assert.True(ContainsAuditLogMutation(stray));
Assert.False(IsAllowListed(stray),
"An UPDATE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed.");
}
[Fact]
public void AllowList_DoesNotForgive_BatchedUpdateWithoutMarker()
{
// A batched UPDATE TOP ... AuditLog without the marker — the TOP clause variant
// must also be caught and not forgiven without the explicit marker.
const string stray = "UPDATE TOP (500) dbo.AuditLog SET SourceNode = 'unknown' WHERE SourceNode IS NULL;";
Assert.True(ContainsAuditLogMutation(stray));
Assert.False(IsAllowListed(stray),
"A batched UPDATE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed.");
}
}
@@ -0,0 +1,237 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
/// <summary>
/// Integration tests for <see cref="AuditLogRepository.BackfillSourceNodeAsync"/>
/// (M5.6 T5 — SourceNode sentinel backfill).
///
/// <para>
/// These tests exercise the real <see cref="AuditLogRepository"/> against a
/// per-class <see cref="MsSqlMigrationFixture"/> database, mirroring the
/// style of <c>PartitionPurgeTests</c>. All tests are guarded with
/// <c>[SkippableFact]</c> and skipped when the MSSQL container is absent.
/// </para>
/// </summary>
public class BackfillSourceNodeTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public BackfillSourceNodeTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaBridgeDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
private AuditLogRepository CreateRepo(ScadaBridgeDbContext ctx) => new(ctx);
// ------------------------------------------------------------------
// Seed helper: direct INSERT bypassing the writer role, same pattern
// as PartitionPurgeTests.DirectInsertAsync.
// ------------------------------------------------------------------
private async Task SeedRowAsync(
SqlConnection conn,
Guid eventId,
DateTime occurredAtUtc,
string? sourceNode)
{
await using var cmd = conn.CreateCommand();
// Supply SourceNode explicitly (NULL or a value) so the test controls
// which rows are eligible for backfill.
cmd.CommandText = @"
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
VALUES
(@EventId, @OccurredAtUtc, NULL, 'ApiOutbound.ApiCall', 'Success', 'ApiOutbound', NULL, @SourceNode, NULL,
@DetailsJson);";
cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId;
var occurredParam = cmd.Parameters.Add("@OccurredAtUtc", System.Data.SqlDbType.DateTime2);
occurredParam.Scale = 7;
occurredParam.Value = occurredAtUtc;
var sourceNodeParam = cmd.Parameters.Add("@SourceNode", System.Data.SqlDbType.VarChar, 64);
sourceNodeParam.Value = (object?)sourceNode ?? DBNull.Value;
var detailsJson =
"{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"," +
"\"payloadTruncated\":false}";
cmd.Parameters.Add("@DetailsJson", System.Data.SqlDbType.NVarChar, -1).Value = detailsJson;
await cmd.ExecuteNonQueryAsync();
}
private async Task<string?> ReadSourceNodeAsync(SqlConnection conn, Guid eventId)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT SourceNode FROM dbo.AuditLog WHERE EventId = @EventId;";
cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId;
var raw = await cmd.ExecuteScalarAsync();
return raw == DBNull.Value ? null : (string?)raw;
}
// ------------------------------------------------------------------
// 1. SetsNullRowsBeforeThreshold
// ------------------------------------------------------------------
[SkippableFact]
public async Task BackfillSourceNode_SetsNullRowsBeforeThreshold()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var before = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
var eligibleId = Guid.NewGuid(); // NULL, occurred before threshold
var tooNewId = Guid.NewGuid(); // NULL, occurred after threshold
await using var seedConn = _fixture.OpenConnection();
await SeedRowAsync(seedConn, eligibleId,
new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), sourceNode: null);
await SeedRowAsync(seedConn, tooNewId,
new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), sourceNode: null);
await using var ctx = CreateContext();
var repo = CreateRepo(ctx);
var rows = await repo.BackfillSourceNodeAsync("unknown", before, batchSize: 1000);
Assert.True(rows >= 1, $"Expected at least 1 row updated; got {rows}.");
// eligible row: must now have the sentinel
var eligibleNode = await ReadSourceNodeAsync(seedConn, eligibleId);
Assert.Equal("unknown", eligibleNode);
// too-new row: must still be NULL
var tooNewNode = await ReadSourceNodeAsync(seedConn, tooNewId);
Assert.Null(tooNewNode);
}
// ------------------------------------------------------------------
// 2. LeavesNonNullRowsUntouched
// ------------------------------------------------------------------
[SkippableFact]
public async Task BackfillSourceNode_LeavesNonNullRowsUntouched()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var before = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
var alreadySetId = Guid.NewGuid(); // already has a SourceNode value
await using var seedConn = _fixture.OpenConnection();
await SeedRowAsync(seedConn, alreadySetId,
new DateTime(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc), sourceNode: "node-a");
await using var ctx = CreateContext();
var repo = CreateRepo(ctx);
await repo.BackfillSourceNodeAsync("unknown", before, batchSize: 1000);
// "node-a" must still be "node-a", not overwritten
var node = await ReadSourceNodeAsync(seedConn, alreadySetId);
Assert.Equal("node-a", node);
}
// ------------------------------------------------------------------
// 3. Idempotent_SecondRunUpdatesZeroRows
// ------------------------------------------------------------------
[SkippableFact]
public async Task BackfillSourceNode_Idempotent_SecondRunUpdatesZeroRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var before = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
var idempotentId = Guid.NewGuid();
await using var seedConn = _fixture.OpenConnection();
await SeedRowAsync(seedConn, idempotentId,
new DateTime(2026, 1, 20, 0, 0, 0, DateTimeKind.Utc), sourceNode: null);
await using var ctx1 = CreateContext();
var repo1 = CreateRepo(ctx1);
var firstRun = await repo1.BackfillSourceNodeAsync("unknown", before, batchSize: 1000);
Assert.True(firstRun >= 1, "First run should update at least 1 row.");
// Second run: no NULL rows remain for this threshold — must update 0.
await using var ctx2 = CreateContext();
var repo2 = CreateRepo(ctx2);
var secondRun = await repo2.BackfillSourceNodeAsync("unknown", before, batchSize: 1000);
// The second run must not update the already-sentinel row again.
// We cannot assert exactly 0 because other tests share the same fixture DB
// and may have left unrelated NULL rows; but the idempotentId row must not
// have been touched (it already has "unknown", so the WHERE SourceNode IS NULL
// filter excludes it).
var node = await ReadSourceNodeAsync(seedConn, idempotentId);
Assert.Equal("unknown", node);
// The second run returning 0 would be true if no other NULL rows exist —
// we assert the contract from the repo's perspective by checking the row.
_ = secondRun; // acknowledged: value consumed
}
// ------------------------------------------------------------------
// 4. CustomSentinelIsWritten
// ------------------------------------------------------------------
[SkippableFact]
public async Task BackfillSourceNode_CustomSentinel_IsWritten()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var before = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
var customId = Guid.NewGuid();
await using var seedConn = _fixture.OpenConnection();
await SeedRowAsync(seedConn, customId,
new DateTime(2026, 2, 5, 0, 0, 0, DateTimeKind.Utc), sourceNode: null);
await using var ctx = CreateContext();
var repo = CreateRepo(ctx);
await repo.BackfillSourceNodeAsync("pre-feature", before, batchSize: 1000);
var node = await ReadSourceNodeAsync(seedConn, customId);
Assert.Equal("pre-feature", node);
}
// ------------------------------------------------------------------
// 5. ArgumentValidation
// ------------------------------------------------------------------
[Fact]
public async Task BackfillSourceNode_EmptySentinel_Throws()
{
// Guard fires even without a DB connection — no Skip needed.
// Use a null/empty context via a degenerate connection string; the
// argument check fires before any SQL runs.
await using var ctx = new ScadaBridgeDbContext(
new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer("Server=.;Database=dummy;Connect Timeout=0;")
.Options);
var repo = new AuditLogRepository(ctx);
await Assert.ThrowsAsync<ArgumentException>(
() => repo.BackfillSourceNodeAsync("", DateTime.UtcNow, 1000));
}
[Fact]
public async Task BackfillSourceNode_ZeroBatchSize_Throws()
{
await using var ctx = new ScadaBridgeDbContext(
new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer("Server=.;Database=dummy;Connect Timeout=0;")
.Options);
var repo = new AuditLogRepository(ctx);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => repo.BackfillSourceNodeAsync("unknown", DateTime.UtcNow, 0));
}
}
@@ -785,4 +785,191 @@ public class AuditEndpointsTests
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);
});
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>());
}
}
}