175 lines
7.7 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|