Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs
T

175 lines
7.7 KiB
C#

using System.Globalization;
using System.Text.Json;
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
/// <summary>
/// Direct-SQL seeding helper for the Audit Log Playwright E2E tests (#23 M7-T16).
///
/// <para>
/// The Playwright suite runs against the live Docker cluster (the same one that
/// answers <c>http://localhost:9000</c>), which talks to the <c>ScadaBridgeConfig</c>
/// database on <c>localhost:1433</c>. <c>infra/mssql/seed-config.sql</c> is off
/// limits per the task's strict rules, so each test inserts its own
/// <c>AuditLog</c> rows at setup time and best-effort deletes them at teardown.
/// </para>
///
/// <para>
/// Rows are tagged with a unique <c>Target</c> prefix derived from the test
/// name + a GUID so the teardown <c>DELETE</c> never touches rows the cluster
/// itself produced. The <c>OccurredAtUtc</c> is pinned to "now" so the default
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit.AuditTimeRangePreset.LastHour"/>
/// time-range filter still sees the row after Apply.
/// </para>
///
/// <para>
/// Connection string mirrors the Docker cluster's <c>scadabridge_app</c> account
/// from <c>docker/central-node-a/appsettings.Central.json</c>, with the host
/// pointed at the host-exposed port (<c>localhost:1433</c>). The
/// <c>SCADABRIDGE_PLAYWRIGHT_DB</c> env var lets CI override the connection
/// without recompiling.
/// </para>
/// </summary>
internal static class AuditDataSeeder
{
/// <summary>
/// Connection string for the running cluster's configuration DB.
/// Delegates to <see cref="PlaywrightDbConnection.ConnectionString"/>.
/// </summary>
public static string ConnectionString => PlaywrightDbConnection.ConnectionString;
/// <summary>
/// Inserts a single audit row into the canonical <c>AuditLog</c> table. After the
/// <c>CollapseAuditLogToCanonical</c> migration the typed audit fields live inside
/// <c>DetailsJson</c> (camelCase), and <c>Kind</c>/<c>Status</c>/<c>SourceSiteId</c>/
/// <c>ExecutionId</c>/<c>ParentExecutionId</c>/<c>IngestedAtUtc</c> are computed columns
/// derived from it — so this seeder writes only the 10 stored columns plus a
/// <c>DetailsJson</c> bag matching the production codec, and lets the computed columns
/// derive automatically. All optional fields are nullable so individual tests can shape
/// the row to whatever payload they need for their drawer/grid assertions.
/// </summary>
public static async Task InsertAuditEventAsync(
Guid eventId,
DateTime occurredAtUtc,
string channel,
string kind,
string status,
string? sourceSiteId = null,
string? target = null,
string? actor = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
int? httpStatus = null,
int? durationMs = null,
string? errorMessage = null,
string? requestSummary = null,
string? responseSummary = null,
string? extra = null,
CancellationToken ct = default)
{
// Typed audit fields ride inside DetailsJson (camelCase, nulls omitted) exactly as
// the production AuditDetailsCodec writes them; the computed columns read these JSON
// paths ($.kind, $.status, $.sourceSiteId, $.executionId, $.parentExecutionId,
// $.ingestedAtUtc). Property order is irrelevant — the readers look up by name.
var details = new Dictionary<string, object?>
{
["channel"] = channel,
["kind"] = kind,
["status"] = status,
};
if (executionId is { } ex) details["executionId"] = ex;
if (parentExecutionId is { } pex) details["parentExecutionId"] = pex;
if (sourceSiteId is not null) details["sourceSiteId"] = sourceSiteId;
if (httpStatus is { } hs) details["httpStatus"] = hs;
if (durationMs is { } dm) details["durationMs"] = dm;
if (errorMessage is not null) details["errorMessage"] = errorMessage;
if (requestSummary is not null) details["requestSummary"] = requestSummary;
if (responseSummary is not null) details["responseSummary"] = responseSummary;
if (extra is not null) details["extra"] = extra;
details["payloadTruncated"] = false;
details["ingestedAtUtc"] = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
var detailsJson = JsonSerializer.Serialize(details);
// Action/Outcome/Category derive from (channel, kind, status) exactly as the
// production canonical factory and the migration's SQL projection do.
var action = $"{channel}.{kind}";
var category = channel;
var outcome =
kind == "InboundAuthFailure" ? "Denied"
: status == "Delivered" ? "Success"
: status is "Failed" or "Parked" or "Discarded" ? "Failure"
: "Success";
const string sql = @"
INSERT INTO [AuditLog]
([EventId], [OccurredAtUtc], [Actor], [Action], [Outcome], [Category],
[Target], [SourceNode], [CorrelationId], [DetailsJson])
VALUES
(@eventId, @occurredAtUtc, @actor, @action, @outcome, @category,
@target, NULL, @correlationId, @detailsJson);";
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(ct);
await using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("@eventId", eventId);
cmd.Parameters.AddWithValue("@occurredAtUtc", occurredAtUtc);
cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
cmd.Parameters.AddWithValue("@action", action);
cmd.Parameters.AddWithValue("@outcome", outcome);
cmd.Parameters.AddWithValue("@category", category);
cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value);
cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@detailsJson", detailsJson);
await cmd.ExecuteNonQueryAsync(ct);
}
/// <summary>
/// Best-effort cleanup. Deletes every <c>AuditLog</c> row whose <c>Target</c>
/// starts with <paramref name="targetPrefix"/>. Swallows all errors — a
/// stuck row carrying a random GUID suffix does not collide with future
/// runs and tests should not fail teardown.
/// </summary>
public static async Task DeleteByTargetPrefixAsync(string targetPrefix, CancellationToken ct = default)
{
try
{
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(ct);
await using var cmd = connection.CreateCommand();
cmd.CommandText = "DELETE FROM [AuditLog] WHERE [Target] LIKE @prefix";
cmd.Parameters.AddWithValue("@prefix", targetPrefix + "%");
await cmd.ExecuteNonQueryAsync(ct);
}
catch
{
// Best-effort — the prefix carries a GUID so the rows are unique to
// this test run and won't collide on the next pass.
}
}
/// <summary>
/// Probe whether the configuration DB is reachable. Tests gate their
/// per-test setup on this; when the cluster is down the test fails with a
/// clear "MSSQL unavailable" message instead of an opaque SqlException.
/// </summary>
public static async Task<bool> IsAvailableAsync(CancellationToken ct = default)
{
try
{
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(ct);
return true;
}
catch
{
return false;
}
}
}