feat(audit): M5.6 SourceNode sentinel backfill (purge-role) + CLI + runbook note (T5)
This commit is contained in:
@@ -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 < <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 > 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]
|
||||
|
||||
+154
-4
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
+237
@@ -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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user