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
+38
View File
@@ -386,6 +386,44 @@ component (Options pattern):
Notification List / Database Connection name. `RetentionDays` is a single
global value in v1; per-channel overrides are deferred to v1.x.
## Ops Notes — Historical Null Columns
### `SourceNode` backfill (M5.6 T5)
`SourceNode` (`varchar(64)` NULL) is a physical column stamped on every row at
write time. Rows ingested before M5.6 shipped have `SourceNode IS NULL` because
the value was not populated until the feature landed. A one-time CLI command sets
these to a configurable sentinel:
```
scadabridge audit backfill-source-node --before <ISO-8601-UTC> [--sentinel unknown] [--batch 5000]
```
The default sentinel is `"unknown"`. The true node-of-origin for pre-feature rows
is **unknowable** retroactively — the emitting node is long gone from the telemetry
pipeline. The sentinel makes that explicit rather than leaving the column NULL
(which the Audit Log UI's Node filter already treats as "unresolved", but which
an operator might mistake for a data-quality bug).
The backfill runs via `POST /api/audit/backfill-source-node` (Admin role required)
on the maintenance/purge path, NOT the append-only `scadabridge_audit_writer` role.
It is idempotent and can be re-run safely.
### `ExecutionId` and `ParentExecutionId` — cannot be backfilled
`ExecutionId` and `ParentExecutionId` are **PERSISTED COMPUTED columns** derived
from `DetailsJson`. They were introduced in the same feature window as the column
itself but their value comes from the JSON payload that was written at ingest time.
The AuditLog append-only invariant **forbids mutating `DetailsJson`** — rows may
only be inserted, never updated. Because backfilling the computed values would
require rewriting the underlying `DetailsJson`, it is impossible under the
append-only contract. Pre-feature rows carry `NULL` in both columns permanently.
This is a documented limitation, not a defect. The NULL values are visible in the
Audit Log UI's execution-tree drilldown (rows with no `ExecutionId` appear as
orphaned entries) and in the CLI's `audit tree` output.
## Dependencies
- **[Commons (#16)](Component-Commons.md)** — `AuditEvent`, `IAuditWriter` /
@@ -0,0 +1,113 @@
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Arguments for an <c>audit backfill-source-node</c> invocation.
/// </summary>
public sealed class AuditBackfillSourceNodeArgs
{
/// <summary>
/// Value written into <c>SourceNode</c> for NULL rows (default <c>"unknown"</c>).
/// </summary>
public string Sentinel { get; set; } = "unknown";
/// <summary>
/// Only rows with <c>OccurredAtUtc</c> strictly before this UTC datetime are
/// eligible. Required — must be an ISO-8601 UTC datetime.
/// </summary>
public string Before { get; set; } = string.Empty;
/// <summary>
/// Maximum rows updated per batch (default 5000). Caps the per-transaction
/// log footprint; the loop repeats until no rows remain.
/// </summary>
public int BatchSize { get; set; } = 5000;
}
/// <summary>
/// Pure helpers for the <c>audit backfill-source-node</c> subcommand (Audit Log
/// #23 M5.6 T5). Builds the request body, POSTs to
/// <c>/api/audit/backfill-source-node</c>, and renders the result. Kept separate
/// from the command wiring so each piece is unit-testable without standing up the
/// command tree.
/// </summary>
public static class AuditBackfillHelpers
{
private static readonly JsonSerializerOptions JsonWriteOptions = new()
{
WriteIndented = true,
};
/// <summary>
/// Builds the JSON request body for <c>POST /api/audit/backfill-source-node</c>.
/// </summary>
/// <param name="args">The backfill arguments.</param>
/// <returns>A JSON string suitable for the request body.</returns>
public static string BuildRequestBody(AuditBackfillSourceNodeArgs args)
{
var obj = new
{
sentinel = args.Sentinel,
before = args.Before,
batchSize = args.BatchSize,
};
return JsonSerializer.Serialize(obj);
}
/// <summary>
/// Executes the backfill: POSTs <c>/api/audit/backfill-source-node</c> and
/// prints the result. Returns the process exit code (0 = success,
/// 1 = error, 2 = authorization failure).
/// </summary>
/// <param name="client">The management HTTP client.</param>
/// <param name="args">The backfill arguments.</param>
/// <param name="output">The output writer for results.</param>
/// <returns>A task that resolves to the process exit code.</returns>
public static async Task<int> RunBackfillAsync(
ManagementHttpClient client,
AuditBackfillSourceNodeArgs args,
TextWriter output)
{
var body = BuildRequestBody(args);
var response = await client.SendPostAsync(
"api/audit/backfill-source-node", body, TimeSpan.FromMinutes(10));
if (response.JsonData == null)
{
OutputFormatter.WriteError(
response.Error ?? "Backfill request failed.", response.ErrorCode ?? "ERROR");
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1;
}
// Parse and display the result.
try
{
using var doc = JsonDocument.Parse(response.JsonData);
var root = doc.RootElement;
var rowsUpdated = root.TryGetProperty("rowsUpdated", out var r)
? r.GetInt64()
: 0L;
var sentinel = root.TryGetProperty("sentinel", out var s)
? s.GetString() ?? args.Sentinel
: args.Sentinel;
var before = root.TryGetProperty("before", out var b)
? b.GetString() ?? args.Before
: args.Before;
output.WriteLine($"SourceNode backfill complete.");
output.WriteLine($" rows updated : {rowsUpdated}");
output.WriteLine($" sentinel : {sentinel}");
output.WriteLine($" before : {before}");
}
catch (JsonException)
{
// Server returned success but non-JSON body — not expected; print raw.
output.WriteLine(response.JsonData);
}
output.Flush();
return 0;
}
}
@@ -29,6 +29,7 @@ public static class AuditCommands
command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildTree(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildBackfillSourceNode(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
@@ -288,4 +289,76 @@ public static class AuditCommands
});
return cmd;
}
/// <summary>
/// Builds the <c>audit backfill-source-node</c> sub-command (Audit Log #23 M5.6 T5).
/// Sets <c>SourceNode</c> on historical pre-feature rows whose <c>SourceNode IS NULL</c>
/// and <c>OccurredAtUtc</c> is older than <c>--before</c>, in batches. Admin-only.
/// </summary>
private static Command BuildBackfillSourceNode(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var sentinelOption = new Option<string>("--sentinel")
{
Description = "Value to write for pre-feature rows whose node-of-origin is unknown (default: unknown)",
};
sentinelOption.DefaultValueFactory = _ => "unknown";
var beforeOption = new Option<string>("--before")
{
Description = "ISO-8601 UTC datetime; only rows older than this date are eligible (required)",
Required = true,
};
var batchOption = new Option<int>("--batch")
{
Description = "Max rows updated per batch (default: 5000)",
};
batchOption.DefaultValueFactory = _ => 5000;
var cmd = new Command("backfill-source-node")
{
Description = "Set SourceNode to a sentinel value on pre-feature rows where it is NULL (admin-only, maintenance path)",
};
cmd.Add(sentinelOption);
cmd.Add(beforeOption);
cmd.Add(batchOption);
cmd.SetAction(async (ParseResult result) =>
{
var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
if (connection.Error != null)
{
OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
return 1;
}
var sentinel = result.GetValue(sentinelOption) ?? "unknown";
var before = result.GetValue(beforeOption)!;
var batch = result.GetValue(batchOption);
if (string.IsNullOrWhiteSpace(sentinel))
{
OutputFormatter.WriteError("--sentinel must be a non-empty string.", "INVALID_ARGUMENT");
return 1;
}
if (batch <= 0)
{
OutputFormatter.WriteError("--batch must be > 0.", "INVALID_ARGUMENT");
return 1;
}
var args = new AuditBackfillSourceNodeArgs
{
Sentinel = sentinel,
Before = before,
BatchSize = batch,
};
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
return await AuditBackfillHelpers.RunBackfillAsync(client, args, Console.Out);
});
return cmd;
}
}
@@ -142,6 +142,60 @@ public class ManagementHttpClient : IDisposable
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
}
/// <summary>
/// Issues a plain HTTP <c>POST</c> against a REST endpoint (e.g. the audit
/// maintenance endpoints) with a JSON body and returns the response. Unlike
/// <see cref="SendCommandAsync"/>, this does not wrap the call in the
/// <c>POST /management</c> command envelope — these are plain REST resources.
/// Authentication (HTTP Basic) and the base address are shared.
/// </summary>
/// <param name="relativePath">Path relative to the base URL.</param>
/// <param name="body">The JSON body to send, or <c>null</c> for an empty body.</param>
/// <param name="timeout">The request timeout.</param>
/// <returns>A management response containing status and data.</returns>
public async Task<ManagementResponse> SendPostAsync(string relativePath, string? body, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
var content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json");
HttpResponseMessage httpResponse;
try
{
httpResponse = await _httpClient.PostAsync(relativePath, content, cts.Token);
}
catch (TaskCanceledException)
{
return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
}
catch (HttpRequestException ex)
{
return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
}
var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
if (httpResponse.IsSuccessStatusCode)
{
return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
}
string? error = null;
string? code = null;
try
{
using var doc = JsonDocument.Parse(responseBody);
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
}
catch
{
error = responseBody;
}
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
}
/// <summary>
/// Issues a plain HTTP <c>GET</c> and returns the raw <see cref="HttpResponseMessage"/>
/// so the caller can stream the response body without buffering it in memory — used
@@ -201,4 +201,59 @@ public interface IAuditLogRepository
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to the distinct, non-null source node names in ascending order.</returns>
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
/// <summary>
/// M5.6 (T5) one-time operational backfill: sets <c>SourceNode</c> to
/// <paramref name="sentinel"/> on every row where <c>SourceNode IS NULL</c>
/// and <c>OccurredAtUtc &lt; <paramref name="before"/></c>, in bounded
/// batches of <paramref name="batchSize"/> rows, looping until no further
/// rows match. Returns the total number of rows updated across all batches.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a sentinel, not the real value.</b> <c>SourceNode</c> captures the
/// physical cluster node on which an event was emitted. For pre-feature rows
/// that were ingested before the column was stamped, the true node-of-origin
/// is UNKNOWABLE — the original emitter is long gone and there is no
/// retroactive way to determine it. Backfilling a configurable sentinel
/// (default <c>"unknown"</c>) makes it explicit that these rows pre-date the
/// feature rather than silently leaving them NULL (which the filter UI already
/// treats as "unresolved" but which an operator might mistake for a bug).
/// </para>
/// <para>
/// <b><c>ExecutionId</c> / <c>ParentExecutionId</c> cannot be backfilled.</b>
/// These are PERSISTED COMPUTED columns derived from <c>DetailsJson</c>. The
/// AuditLog append-only invariant forbids mutating <c>DetailsJson</c>, so
/// the computed values for pre-feature rows remain NULL permanently. This is
/// documented rather than coded — see the Ops Note in
/// <c>Component-AuditLog.md § Ops Notes — Historical Null Columns</c>.
/// </para>
/// <para>
/// <b>Maintenance path — NOT the writer role.</b> This UPDATE runs on the
/// purge/maintenance connection (the same path as
/// <see cref="SwitchOutPartitionAsync"/> and any per-channel purge), NOT the
/// append-only <c>scadabridge_audit_writer</c> role. The CI guard
/// (<c>AuditLogAppendOnlyGuardTests</c>) recognises the
/// <c>// AUDIT-PURGE-ALLOWED</c> marker on the UPDATE line and forgives
/// exactly this one sanctioned maintenance-path UPDATE; any other UPDATE
/// against <c>AuditLog</c> still trips the guard.
/// </para>
/// <para>
/// <b>Bounded + idempotent.</b> <c>UPDATE TOP (@batch)</c> caps the
/// transaction-log and lock footprint per statement. The loop exits when a
/// batch updates zero rows, so a crash mid-loop is recoverable by simply
/// running again; re-running after completion is a no-op (no NULL rows
/// remain for the given <paramref name="before"/> window).
/// </para>
/// </remarks>
/// <param name="sentinel">Value to write into <c>SourceNode</c> for pre-feature rows (e.g. <c>"unknown"</c>).</param>
/// <param name="before">Rows with <c>OccurredAtUtc</c> strictly older than this UTC datetime are eligible.</param>
/// <param name="batchSize">Maximum rows updated per batch; must be &gt; 0.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to the total number of rows updated across all batches.</returns>
Task<long> BackfillSourceNodeAsync(
string sentinel,
DateTime before,
int batchSize,
CancellationToken ct = default);
}
@@ -716,6 +716,102 @@ VALUES
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<long> BackfillSourceNodeAsync(
string sentinel,
DateTime before,
int batchSize,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(sentinel))
{
throw new ArgumentException("Sentinel must be a non-empty value.", nameof(sentinel));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be > 0.");
}
var beforeUtc = DateTime.SpecifyKind(before.ToUniversalTime(), DateTimeKind.Utc);
// M5.6 (T5) SourceNode sentinel backfill. This is the ONE sanctioned UPDATE
// against dbo.AuditLog in the codebase. It touches ONLY rows where
// SourceNode IS NULL AND OccurredAtUtc < @before — rows that pre-date the
// M5.6 feature and whose node-of-origin is UNKNOWABLE. The sentinel (default
// "unknown") makes that explicit. ExecutionId/ParentExecutionId are PERSISTED
// COMPUTED columns derived from DetailsJson — mutating DetailsJson is forbidden
// under the append-only invariant, so those stay NULL on pre-feature rows.
//
// Maintenance path (NOT the writer role): runs on the same connection used for
// SwitchOutPartitionAsync (partition-switch DDL), which requires a role that
// holds UPDATE — the append-only scadabridge_audit_writer role has only
// INSERT + SELECT.
//
// Bounded + idempotent: UPDATE TOP (@batch) caps the log/lock footprint per
// statement; the loop exits when a batch updates 0 rows. Re-running after a
// crash simply resumes where it left off.
//
// The trailing AUDIT-PURGE-ALLOWED marker on the UPDATE line below is the
// single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests)
// recognises for an UPDATE; any other UPDATE targeting AuditLog still trips the guard.
const string updateBatchSql =
"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
long totalUpdated = 0;
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
while (true)
{
ct.ThrowIfCancellationRequested();
await using var cmd = conn.CreateCommand();
cmd.CommandText = updateBatchSql;
var pBatch = cmd.CreateParameter();
pBatch.ParameterName = "@batch";
pBatch.Value = batchSize;
cmd.Parameters.Add(pBatch);
var pSentinel = cmd.CreateParameter();
pSentinel.ParameterName = "@sentinel";
pSentinel.Value = sentinel;
cmd.Parameters.Add(pSentinel);
var pBefore = cmd.CreateParameter();
pBefore.ParameterName = "@before";
pBefore.Value = beforeUtc;
cmd.Parameters.Add(pBefore);
var rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
if (rows <= 0)
{
break;
}
totalUpdated += rows;
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
return totalUpdated;
}
/// <summary>
/// Splits a <c>STRING_AGG</c> comma-joined value into a distinct, ordered
/// list. A null/empty aggregate (a stub node with no rows) yields an empty
@@ -89,9 +89,16 @@ public static class AuditEndpoints
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Default sentinel written by the backfill endpoint when the caller omits <c>sentinel</c>.</summary>
public const string DefaultBackfillSentinel = "unknown";
/// <summary>Default batch size for the backfill endpoint when the caller omits <c>batchSize</c>.</summary>
public const int DefaultBackfillBatchSize = 5000;
/// <summary>
/// Registers the <c>/api/audit/query</c>, <c>/api/audit/export</c>, and
/// <c>/api/audit/tree</c> minimal-API endpoints.
/// Registers the <c>/api/audit/query</c>, <c>/api/audit/export</c>,
/// <c>/api/audit/tree</c>, and <c>POST /api/audit/backfill-source-node</c>
/// minimal-API endpoints.
/// </summary>
/// <param name="endpoints">The endpoint route builder to register routes on.</param>
/// <returns>The same <paramref name="endpoints"/> builder, for chaining.</returns>
@@ -100,6 +107,7 @@ public static class AuditEndpoints
endpoints.MapGet("/api/audit/query", (Delegate)HandleQuery);
endpoints.MapGet("/api/audit/export", (Delegate)HandleExport);
endpoints.MapGet("/api/audit/tree", (Delegate)HandleTree);
endpoints.MapPost("/api/audit/backfill-source-node", (Delegate)HandleBackfillSourceNode);
return endpoints;
}
@@ -279,6 +287,136 @@ public static class AuditEndpoints
return Results.Json(nodes, JsonOptions);
}
// ─────────────────────────────────────────────────────────────────────
// POST /api/audit/backfill-source-node
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Handles <c>POST /api/audit/backfill-source-node</c>: authenticates (Admin role
/// required), reads the JSON body for <c>sentinel</c> / <c>before</c> /
/// <c>batchSize</c>, and calls
/// <see cref="IAuditLogRepository.BackfillSourceNodeAsync"/> on the maintenance
/// path.
///
/// <para>
/// <b>Auth.</b> Admin-only — backfilling the SourceNode column is a one-time ops
/// procedure that mutates the AuditLog table via the maintenance path (NOT the
/// append-only writer role). Restricted to <see cref="AuthorizationPolicies.AuditExportRoles"/>
/// (Administrator) so it is never accessible to Viewer-role users.
/// </para>
///
/// <para>
/// <b>Request body.</b>
/// <code>
/// {
/// "sentinel": "unknown", // optional; default "unknown"
/// "before": "2026-01-01T00:00:00Z", // required ISO-8601 UTC
/// "batchSize": 5000 // optional; default 5000
/// }
/// </code>
/// </para>
///
/// <para>
/// <b>Response (200).</b>
/// <code>{ "rowsUpdated": 12345, "sentinel": "unknown", "before": "2026-01-01T00:00:00Z" }</code>
/// </para>
/// </summary>
/// <param name="context">The HTTP context for the current request.</param>
/// <returns>A task that resolves to the HTTP result (200 JSON, 400, 401, or 403).</returns>
internal static async Task<IResult> HandleBackfillSourceNode(HttpContext context)
{
var auth = await AuthenticateAsync(context);
if (auth.Failure is not null)
{
return auth.Failure;
}
// Admin-only: backfilling is a one-time ops procedure on the maintenance path.
if (!HasAnyRole(auth.User!, AuthorizationPolicies.AuditExportRoles))
{
return Forbidden("Administrator");
}
string bodyText;
try
{
using var reader = new System.IO.StreamReader(context.Request.Body);
bodyText = await reader.ReadToEndAsync(context.RequestAborted);
}
catch (OperationCanceledException)
{
return Results.Json(new { error = "Request cancelled.", code = "CANCELLED" }, statusCode: 499);
}
string sentinel = DefaultBackfillSentinel;
DateTime? beforeUtc = null;
int batchSize = DefaultBackfillBatchSize;
if (!string.IsNullOrWhiteSpace(bodyText))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(bodyText);
var root = doc.RootElement;
if (root.TryGetProperty("sentinel", out var sentinelEl))
{
var s = sentinelEl.GetString();
if (!string.IsNullOrWhiteSpace(s))
{
sentinel = s.Trim();
}
}
if (root.TryGetProperty("before", out var beforeEl))
{
if (DateTime.TryParse(
beforeEl.GetString(),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out var parsed))
{
beforeUtc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
else
{
return Results.Json(
new { error = "Invalid 'before' value; expected ISO-8601 UTC datetime.", code = "BAD_REQUEST" },
statusCode: 400);
}
}
if (root.TryGetProperty("batchSize", out var batchEl) && batchEl.TryGetInt32(out var b) && b > 0)
{
batchSize = b;
}
}
catch (System.Text.Json.JsonException)
{
return Results.Json(
new { error = "Request body must be valid JSON.", code = "BAD_REQUEST" },
statusCode: 400);
}
}
if (beforeUtc is null)
{
return Results.Json(
new { error = "Required field 'before' (ISO-8601 UTC datetime) is missing.", code = "BAD_REQUEST" },
statusCode: 400);
}
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
var rowsUpdated = await repo.BackfillSourceNodeAsync(sentinel, beforeUtc.Value, batchSize, context.RequestAborted);
return Results.Json(new
{
rowsUpdated,
sentinel,
before = beforeUtc.Value.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
}, JsonOptions);
}
/// <summary>
/// Streams every matching row as RFC 4180 CSV, paging the repository with its
/// keyset cursor and flushing after each page so a large export starts
@@ -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>());
}
}
}